Skip to content

feat: per-company screenshot retention policy + nightly cleanup cron#157

Merged
parth0025 merged 3 commits into
stagingfrom
feature/screenshot-retention
May 18, 2026
Merged

feat: per-company screenshot retention policy + nightly cleanup cron#157
parth0025 merged 3 commits into
stagingfrom
feature/screenshot-retention

Conversation

@parth0025

Copy link
Copy Markdown
Collaborator

Adds an opt-in retention policy on the time-tracker screenshot pipeline. Visible on /settings/setting to the company owner (roleType === 1) only. When enabled, a nightly cron permanently deletes trackshots older than the configured window (3 / 6 / 12 / 24 months) from both the per-tenant TimeSheet.trackShots subdoc array and from Wasabi (main object + every thumbnail variant).

Backend

  • utils/mongo-handler/schema.js: new screenshotRetention Map field on the global companies schema. Stores enabled, maxAgeMonths, enabledAt, enabledBy, lastRunAt, lastRunStats, firstRunCompletedAt, runningSince. Backward-compatible β€” legacy companies without the field read as enabled: false.

  • Modules/ScreenshotRetention/: new module.
    helper.js β€” policy read/write, preview counter, per-company
    cleanup workflow, and the cron entry point.
    Production-ready guarantees:
    * Wasabi delete BEFORE db $pull so transient
    Wasabi failures leave the db record intact and
    the next nightly run retries (no permanent
    orphans).
    * Per-trackshot main + 4 thumbnail keys deleted
    (sizes hard-coded from thumbnail.json).
    * Filters by trackshot.screenShotTime (epoch ms),
    not parent TimeSheet timestamp.
    * Advisory runningSince lock prevents
    double-runs; stale locks (>4h) are reclaimed.
    * First-run safety cap (50k deletions) for the
    initial cleanup on legacy data; lifted once
    firstRunCompletedAt is stamped.
    * Bounded company concurrency (5 in parallel)
    via Promise.allSettled.
    controller.js β€” three endpoints with owner role check:
    GET /api/v1/screenshot-retention
    GET /api/v1/screenshot-retention/preview
    PUT /api/v1/screenshot-retention
    Owner check looks up the per-tenant
    company_users doc for the caller and confirms
    roleType === 1. Returns 403 on mismatch.
    routes.js β€” endpoint registration.
    init.js β€” module bootstrap (matches existing convention).

  • index.js: register the new module beside the rest of initializeControllers().

  • cron.js: removed the broken cleanUpTrackshot() call (was referencing an unimported binding and never executed). Added a new schedule at 00:30 UTC that invokes screenshotRetention.runRetentionForAllCompanies(). Off-peak vs the other midnight jobs so a heavy cleanup doesn't compound with the bucket-size + AI-reset jobs on the same minute.

Frontend

  • frontend/src/components/molecules/Setting/SettingScreenshotRetention.vue: new card mounted on /settings/setting. Renders only for companyUser.roleType === 1. Toggle + retention-window dropdown + last-run telemetry. Enabling fires a SweetAlert confirmation that shows the preview-count from the new GET preview endpoint so the owner knows exactly what will be deleted on the next nightly run.

  • frontend/src/views/Settings/Setting/Setting.vue: mount the new component in the existing right-hand column. Self-hides for non-owners.

  • frontend/src/locales/en.js: new ScreenshotRetention.* keys (heading, toggle/window labels, confirmation copy, last-run telemetry). Other locales fall back to English via vue-i18n.

Out of scope (deferred)

  • Translation backfill for non-English locales.
  • A RetentionAuditLog collection for per-run history beyond lastRunStats. The cron emits structured logs in the meantime.
  • A "dry run" mode that lets the owner see what would be deleted without enabling the policy. The preview endpoint already gives the count; a per-record preview would need its own UI.

Pull Request Template Chooser

Please click the link that matches your contribution type to load the correct format.

Note: Clicking a link will reload this page and clear any text you've already typed here.

  • Bug Fix
    Use this for fixing broken logic or UI glitches.

  • New Feature
    Use this for adding new functionality or components.

  • Refactor
    Use this for code cleanup, performance tweaks, or technical debt.


General Summary

If you don't want to use a specific template, please provide a brief summary of your changes below.

Adds an opt-in retention policy on the time-tracker screenshot pipeline.
Visible on /settings/setting to the company owner (roleType === 1)
only. When enabled, a nightly cron permanently deletes trackshots
older than the configured window (3 / 6 / 12 / 24 months) from both
the per-tenant TimeSheet.trackShots subdoc array and from Wasabi
(main object + every thumbnail variant).

Backend
=======
- utils/mongo-handler/schema.js: new `screenshotRetention` Map field
  on the global companies schema. Stores `enabled`, `maxAgeMonths`,
  `enabledAt`, `enabledBy`, `lastRunAt`, `lastRunStats`,
  `firstRunCompletedAt`, `runningSince`. Backward-compatible β€” legacy
  companies without the field read as `enabled: false`.

- Modules/ScreenshotRetention/: new module.
    helper.js   β€” policy read/write, preview counter, per-company
                  cleanup workflow, and the cron entry point.
                  Production-ready guarantees:
                    * Wasabi delete BEFORE db $pull so transient
                      Wasabi failures leave the db record intact and
                      the next nightly run retries (no permanent
                      orphans).
                    * Per-trackshot main + 4 thumbnail keys deleted
                      (sizes hard-coded from thumbnail.json).
                    * Filters by trackshot.screenShotTime (epoch ms),
                      not parent TimeSheet timestamp.
                    * Advisory `runningSince` lock prevents
                      double-runs; stale locks (>4h) are reclaimed.
                    * First-run safety cap (50k deletions) for the
                      initial cleanup on legacy data; lifted once
                      `firstRunCompletedAt` is stamped.
                    * Bounded company concurrency (5 in parallel)
                      via Promise.allSettled.
    controller.js β€” three endpoints with owner role check:
                    GET  /api/v1/screenshot-retention
                    GET  /api/v1/screenshot-retention/preview
                    PUT  /api/v1/screenshot-retention
                    Owner check looks up the per-tenant
                    `company_users` doc for the caller and confirms
                    `roleType === 1`. Returns 403 on mismatch.
    routes.js     β€” endpoint registration.
    init.js       β€” module bootstrap (matches existing convention).

- index.js: register the new module beside the rest of
  `initializeControllers()`.

- cron.js: removed the broken `cleanUpTrackshot()` call (was
  referencing an unimported binding and never executed). Added a new
  schedule at 00:30 UTC that invokes
  `screenshotRetention.runRetentionForAllCompanies()`. Off-peak vs the
  other midnight jobs so a heavy cleanup doesn't compound with the
  bucket-size + AI-reset jobs on the same minute.

Frontend
========
- frontend/src/components/molecules/Setting/SettingScreenshotRetention.vue:
  new card mounted on /settings/setting. Renders only for
  `companyUser.roleType === 1`. Toggle + retention-window dropdown +
  last-run telemetry. Enabling fires a SweetAlert confirmation that
  shows the preview-count from the new GET preview endpoint so the
  owner knows exactly what will be deleted on the next nightly run.

- frontend/src/views/Settings/Setting/Setting.vue: mount the new
  component in the existing right-hand column. Self-hides for
  non-owners.

- frontend/src/locales/en.js: new ScreenshotRetention.* keys (heading,
  toggle/window labels, confirmation copy, last-run telemetry). Other
  locales fall back to English via vue-i18n.

Out of scope (deferred)
=======================
- Translation backfill for non-English locales.
- A `RetentionAuditLog` collection for per-run history beyond
  `lastRunStats`. The cron emits structured logs in the meantime.
- A "dry run" mode that lets the owner see what would be deleted
  without enabling the policy. The preview endpoint already gives the
  count; a per-record preview would need its own UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@parth0025 parth0025 self-assigned this May 15, 2026
parth0025 and others added 2 commits May 15, 2026 18:15
Independent review of the original commit surfaced three blockers and
several correctness issues. This is a follow-up commit on the same
branch that addresses all of them.

BLOCKERS
========

1. **Routes had no JWT middleware.** Config/setMiddleware.js gates auth
   via path allowlists. The new `/api/v1/screenshot-retention` and
   `/api/v1/screenshot-retention/preview` paths were NOT listed in
   either `verifyJWTTokenWithCRoute` or `verifyJWTToken`, so the
   endpoints shipped completely unauthenticated β€” anyone on the
   internet could toggle retention for any company. Added both paths
   to `verifyJWTTokenWithCRoute` so the JWT middleware fires and
   populates `req.uid` / `req.aud`.

2. **Thumbnail keys were generated in the wrong dimension order.** The
   upload-time call at Modules/storage/wasabi/controller.js:298 passes
   `(thu.height, thu.width)` into a function whose params are
   `(width, height)`, so the stored filename is effectively
   `<base>-<height>x<width>.<ext>`. The cleanup helper was building
   `<base>-<width>x<height>.<ext>` and missing every thumbnail variant
   on every retention run β€” leaving four orphan Wasabi objects per
   trackshot, permanently. derivThumbnailKeys now mirrors the
   upload-time swap with a comment explaining the pre-existing bug
   in the upload path (out of scope to fix here).

3. **Cursor-based pagination replaces skip-based scan.** The previous
   loop advanced `pagedSkip += docs.length` after each batch, but
   the `$pull` inside the loop removes docs from the match set β€”
   meaning the next page's skip lands past where it should. Most of
   the DB was being missed on legacy-data tenants. Replaced with
   `_id > lastSeenId` cursor that's immune to in-flight match-set
   shrinkage.

SECURITY
========

4. **Controller now reads userId from `req.uid`** (set by
   Config/jwt.js#checkToken from the verified token's `uid` claim),
   not from `req.body.userId`. The old code let a non-owner pass the
   owner's userId in the body and pass the role check. Status codes
   updated: missing userId β†’ 401 (authentication required) rather
   than 400.

HIGH
====

5. **Legacy string-typed `screenShotTime` is now handled.** Multipart
   form uploads coerce numbers to strings at write time, so the
   `screenShotTime` field on legacy trackshots is stored as a string.
   The previous `$lt: cutoffMs` (number) didn't match strings.
   - `countOldTrackshots` now uses `$convert` with
     `to: 'long', onError: null` inside the $filter so the comparison
     works regardless of the field's actual storage type.
   - `runRetentionForCompany`'s loose DB query + strict in-memory
     filter coerces via `Number(t.screenShotTime)` and skips
     non-finite values.

6. **`firstRunCompletedAt` is now stamped only when a real cleanup
   completed.** Previous condition `deletedCount < cap` stamped the
   marker on a zero-deletion run (e.g. a new company with no eligible
   data), which lifted the first-run safety cap before any real bulk
   data accumulated. New condition: scan exhausted (no cap hit, no
   error) AND deletedCount > 0.

7. **Empty-image trackshots no longer inflate `deletedCount`.** The
   scan loop now pre-filters them out (`oldShots` requires `t.image`),
   and `deleteTrackshotObjects` returns `skipped: true` rather than
   `mainDeleted: true` if it's ever called with a missing key.
   Stats now track `skippedCount` separately.

MEDIUM
======

8. **Removed the no-op `updateCompanyPolicy(companyId, {}, {})`
   call** that hit the empty-patch early-return at line 176 without
   writing anything. Lock acquisition is a single `stampMasterField`
   call, which is at least document-atomic. Cluster-aware leader
   election is still out of scope (documented).

9. **`lastRunStats` now records partial-run errors and cap-hit state.**
   New stats fields: `skippedCount`, `hitCap`, `error`. Ops can tell
   from the master doc whether the run completed cleanly, hit the
   cap, or threw mid-loop.

10. **Frontend now uses `watch(isOwner, …, {immediate: true})`** instead
    of one-shot `onMounted`. The Vuex `companyUserDetail` store
    sometimes hydrates after the component mounts on soft route
    changes; the one-shot mount would never call `loadPolicy()` in
    that case and the card stayed on hard-coded defaults forever.

LOW
===

11. **`extractKey` regex tightened** from `[a-f0-9]{20,}` to
    `[a-f0-9]{24}` (exact ObjectId length) and now decodes URL
    path segments so percent-encoded keys map back to their
    canonical storage form.

12. **`getS3Client` doc + body cleaned up.** `s3Client` is not exported
    from `wasabi/controller.js`, so the old "fall back if not
    available" branch was always taken. Removed the misleading
    primary path; we just build the client from `awsRef` and
    memoise it.

Test plan addendum
==================
- Hit `GET /api/v1/screenshot-retention` without a JWT β†’ 401.
- Hit `PUT /api/v1/screenshot-retention` with a forged body
  `userId` of the owner from a non-owner JWT β†’ 403 (controller
  uses `req.uid`, not body).
- On a tenant with legacy string-typed `screenShotTime`:
  - GET /preview returns a non-zero count for the configured window.
  - A nightly run deletes those records + their (heightΓ—width-named)
    thumbnail variants.
- Confirm thumbnail keys deleted in Wasabi by listing the bucket
  after a run β€” there should be no orphans for the deleted main
  keys.
- Confirm pagination handles a tenant whose every TimeSheet has at
  least one expired trackshot (all docs drop from match set on
  first $pull): the cursor variant should still terminate cleanly
  and process every doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@parth0025 parth0025 merged commit c01757b into staging May 18, 2026
joshishiv4 added a commit that referenced this pull request Jun 16, 2026
* fix(stability): surface allSettled rejection reasons (BUG-018 / #72)

The audit description for BUG-018 β€” `Promise.allSettled rejection
branch never responds, outer Promise stays pending` β€” turned out to be
inaccurate in two ways once verified against the source:

  1. The `else` branch on `if (rejected.length === 0)` already existed
     and called `reject({status: false, statusText: 'error in
     createProject'})`. The outer Promise did NOT hang.
  2. `Promise.allSettled` never rejects in the first place β€” it always
     resolves with the per-promise outcomes. So even without an else,
     the surrounding `.then` would still fire.

What WAS broken (and is fixed here) is the loss of debugging info: the
existing else body was a generic statusText, with every per-query
`reason` in the `rejected` array discarded. The caller and the log
learnt nothing about WHICH prerequisite query failed or WHY.

Replace the else's reject body with a structured payload that includes:

  - `rejectedCount` / `totalCount`
  - `reasons`: array of `{index, reason}` carrying the actual error
    message for each failed prerequisite query.

…and log the same array via `logger.error` so the failure is also in
the server log for ops.

Closes #72

* fix(stability): declare setChat with const (BUG-019 / #73)

`Modules/MainChats/controller.js:71` did:

    setChat = await MongoDbCrudOpration(req.headers['companyid'], …);

with no `const`/`let`/`var`. That makes `setChat` an implicit global on
the Node process. Two concurrent requests share the same slot and can
clobber each other β€” the second response can include the first
request's data. (Under strict mode the assignment throws
`ReferenceError`; the file isn't strict today, so the live behaviour is
the silent cross-request leak.)

Trivial fix: add `const`. The variable is only used on the very next
line, so block scope is the right shape.

Closes #73

* fix(stability): align cache set/remove key in MainChats (BUG-020 / #74)

`Modules/MainChats/controller.js` had a cache-key mismatch between
writer and invalidator:

  - `getChats` set the cache at
        `mainChat:${req.headers['companyid']}`
  - `updateMainChat` tried to invalidate at
        `mainChat:${JSON.parse(JSON.stringify(result))?._id}`
    β€” i.e. the updated document's `_id`, NOT the companyId.

The two keys never matched. `removeCache(...)` was a silent no-op and
stale chats stayed cached until the 3600s TTL expired.

Centralise the key in a small helper so both sites use the same shape:

    const mainChatCacheKey = (companyId) => `mainChat:${companyId}`;

…and use it for both `myCache.set` and `removeCache`. The helper is
exported so the regression test can pin the shape.

Closes #74

* perf: add MongoDB indexes for hot query paths (BUG-021 / #75)

`utils/mongo-handler/createSchema.js` declared exactly one index β€” the
`sessions` TTL β€” and left every other collection un-indexed. Every
multi-field filter (tasks by project/sprint, history by task, users
by email, etc.) was a collection scan, scaling linearly with data
size.

Add compound + single-field indexes for the hottest filter paths the
codebase actually uses. ESR (Equality β†’ Sort β†’ Range) order where
applicable. Mongoose creates indexes at startup via `ensureIndexes`;
existing collections will see one-time background builds, which is
fine for any non-trivial dataset.

Per-company collections:
  - tasks:    (ProjectID, sprintId, deletedStatusKey)
              (sprintId, deletedStatusKey)
              (AssigneeUserId), (ParentTaskId), (TaskKey)
  - comments: ('objId.taskId', deletedStatusKey)
              ('objId.sprintId'), ('objId.projectId')
  - history:  (TaskId, createdAt: -1), (ProjectId, createdAt: -1)
  - timesheet: (TicketID), (userId, ProjectId)
  - userId:   (userId)
  - sprints:  (ProjectID, deletedStatusKey)
  - folders:  (ProjectID)
  - projects: (deletedStatusKey)

Global DB collections:
  - users:    (Employee_Email), (AssignCompany)
              The AssignCompany index is specifically required by the
              BUG-013 per-request membership re-check that landed in
              PR #114.
  - userAuth:    (email)
  - companyUsers: (userId)
  - sessions:    (refreshToken), (userId)  (TTL on createdAt already exists)
  - resetAttempt: (ip)

Note on multi-tenancy: each company has its own MongoDB database, so
the document-level `companyId` field is redundant with the database
name and not indexed here. The audit's "missing companyId index"
framing was inaccurate; what's needed are the per-collection filter
indexes added above.

Closes #75

* chore(deps): upgrade sharp 0.32.6 β†’ ^0.34.0 (BUG-022 / #76)

`sharp@0.32.6` carries CVE-2024-28219 (heap-buffer overflow via crafted
SVG). The fix was released in 0.33.2; bumping to the current major
(^0.34.0) β€” installed as 0.34.5 here β€” picks up that fix and several
subsequent security/perf releases.

The two existing callers in `Modules/storage/...` use the long-stable
`sharp(input).resize().withMetadata().toFile(...)` shape, which is
unchanged across 0.32 β†’ 0.34, so no code changes are required.

A regression test at `.claude/tests/test-bug-022.js` (local, not
committed) confirms package.json + the installed module + a runtime
resize/metadata round-trip.

Closes #76

* fix(security): guard sharp() inputs against pixel-bomb DoS (BUG-023 / #77)

`Modules/storage/wasabi/controller.js` and
`Modules/storage/server/helpers/bucket.helper.js` invoke `sharp(buffer)`
and `sharp(file.path)` with no validation on either the input file size
or the image's pixel dimensions. An authenticated user could upload a
30000x30000 PNG and OOM the worker β€” `bodyParser` only limits the
request envelope, not what libvips materialises in memory.

New shared helper at `utils/imageGuard.js`:

  - `guardFile(filePath)`  β€” fs.statSync size check + metadata() check
  - `guardBuffer(buffer)`  β€” buffer-length check + metadata() check
  - `getLimits()` reads `MAX_IMAGE_FILE_BYTES` and `MAX_IMAGE_PIXELS`
    from env (defaults 25 MB and 50 megapixels). Both clamp to sane
    minimums so a misconfigured 0 doesn't accidentally disable the
    guard.
  - On rejection, throws `ImageGuardError` with `statusCode: 413` and
    a stable `code` (`IMAGE_TOO_LARGE` / `IMAGE_TOO_MANY_PIXELS`) for
    the calling controller to surface to the client.

Wired into all five sharp() callsites:

  - wasabi/controller.js: uploadThumbnailFile (file), uploadThumbnailFileFromBase64 (buffer)
  - bucket.helper.js: uploadStorageThumbnailFile (file), uploadStorageThumbnailFilev2 (file/buffer)

The metadata-only sharp().metadata() call inside the guard does NOT
materialise the full image β€” it parses the header only, so the guard
itself cannot be DoS'd by the same input it's meant to reject.

`.env.example` documents the two new variables.

Closes #77

* perf: switch readFileSync in request handlers to async (BUG-024 / #78)

`Modules/storage/wasabi/controller.js` had five `fs.readFileSync` calls
inside request-handler flows (file uploads to Wasabi):

  - updateLocalWasabiFiles            (line 80)
  - uploadThumbnailFile               (line 619)
  - uploadFileWasabiPromise           (lines 695, 758)
  - uploadPublicAssetsToWasabi        (line 1010)

…and `Modules/notification/notification-middleware/controllerV2.js`
had a sixth `readFileSync` on every push-notification request to read
`brandSettings.json`.

Every one of these blocks the Node event loop for the duration of the
disk read. Under concurrent load, p99 latency on completely unrelated
routes spiked because they were stuck waiting on the event loop. The
sharp() resizing path was particularly bad because it reads the
resulting thumbnail back synchronously after writing it.

Switch each site to `await fs.promises.readFile(...)` and adjust the
surrounding Promise/callback structure:

  - Promise constructors become `async (resolve, reject) => { … }` so
    `await` is legal inside.
  - The sharp `.toFile(outputFile, async (err) => { … })` callback
    becomes async too.
  - Each read is wrapped in try/catch that calls `reject` with the
    underlying message instead of bubbling an uncaught exception.
  - `uploadPublicAssetsToWasabi` was already `async`, so the change
    there is a one-liner.

The notification handler additionally wraps the file-exists check in
try/catch so a permissions error doesn't unhandled-promise-reject and
crash the worker.

Closes #78

* fix(observability): route console.* through Winston (BUG-025 / #79)

Auth + MainChats had several `console.log` / `console.error` calls
that printed PII and error stacks to raw stdout, which in hosted
deployments lands in shared aggregators searchable by anyone with
log access.

Replace each call in:

  - Modules/auth/controller/sendInvitation.js  (5 sites)
  - Modules/MainChats/controller.js            (3 sites)

…with `logger.info` / `logger.error` from the existing Winston
config. Messages now go through the same redaction-able structured
pipeline as the rest of the app.

This PR covers the two files explicitly named in the audit. A
broader sweep across the codebase is possible but kept out of
scope to keep the diff reviewable.

Closes #79

* fix(stability): strict equality on isEmailVerified check (BUG-026 / #80)

`Modules/Users/controller.js:74` used `response.isEmailVerified ==
false`, which also matches `0`, `""`, `null`, `undefined`, `NaN`. A
user document missing the field entirely (legacy data, fresh records
before the field was added) would be classified as "Email Not
Verified" even though it was never explicitly false.

Swap to `=== false`. Documents with the field missing now fall
through to the next branch, which is the intended behaviour.

Audit-accuracy note: the audit referenced `Modules/usersModule/...`,
which doesn't exist after the naming-conventions refactor β€” the real
site is `Modules/Users/controller.js`. The audit mentioned the same
pattern was "widespread" but a fresh codebase grep finds this is the
only `== false` against `isEmailVerified`.

Closes #80

* fix(stability): guard checkUserAndCompany against duplicate res.send (BUG-027 / #81)

`Modules/Users/controller.js` `checkUserAndCompany` has six different
`res.send` sites across three nested .then / .catch chains. Today the
early-return pattern keeps them mutually exclusive in normal traffic,
but the structure is fragile:

  - If `res.send` throws synchronously inside the inner `.then`
    (e.g. ERR_HTTP_HEADERS_SENT triggered by upstream middleware), the
    sync throw is caught by the inner `.catch`, which then calls
    `res.send` AGAIN β€” a guaranteed cascade.
  - Any future refactor that adds another branch without an explicit
    `return` re-introduces the race.

Wrap every `res.send` through a local `sendOnce(payload)` helper backed
by a boolean flag. Duplicate writes are suppressed with a `logger.warn`
instead of throwing, and the request only ever produces one response.

(Also picks up a stray `== false` from BUG-026 in the same function
that was already fixed on another branch, harmless if both merge.)

Closes #81

* fix: correct copy-paste error in companyId validation message (BUG-028 / #82)

`Modules/logTime/controllerV2.js:77` returned "ProjectId is required"
when the missing field was actually companyId. One-character fix
(copy-paste from the adjacent projectId check at line 70).

Closes #82

* fix: validate timeDuration shape before .split(':') (BUG-029 / #83)

`Modules/logTime/controllerV2.js:162` did
`req.body.timeDuration.split(':')` after only a falsy-existence check
at line 53. That left two crash / data-corruption paths open:

  - Non-string values that pass `!req.body.timeDuration` (e.g. an array
    `[]`, an object `{}`, a number `42`) reach `.split` and either
    crash with `TypeError: timeDuration.split is not a function` or
    produce surprising arrays.
  - A string without a colon (e.g. "1") returns `["1"]` from .split;
    `diffArr[1]` is undefined, `+undefined` is NaN, and `diffMin` is
    NaN. That NaN then writes straight into Mongo as the log duration.

Add an explicit shape check before the split: `typeof === 'string'`
AND matches `/^\d+:\d+$/`. Any other value gets a clean 400 with
"timeDuration must be a string in HH:MM format".

Closes #83

* fix(stability): guard against missing response.data in sprints (BUG-030 / #84)

`Modules/sprints/controller.js:45` did
    const data = JSON.parse(JSON.stringify(response.data));
then read `data.projectCount.privateChannels` /
`data.planFeature.maxPrivateChannels`. If `response.data` was
undefined (race with a newly-created company, malformed write, etc.)
the surrounding Promise crashed with TypeError on the first nested
field access.

Guard up front:

  - If `response` or `response.data` is null/undefined, log a warning
    and resolve(false) (the "not allowed" outcome) instead of throwing.
  - Default `projectCount` and `planFeature` to `{}` so individual
    missing sub-fields evaluate to `undefined` instead of crashing.

The audit framed this as "JSON.parse throws on undefined" β€” actually
JSON.parse(JSON.stringify(undefined)) returns undefined silently. The
real crash was the subsequent property chain on the now-undefined
`data`.

Closes #84

* fix(observability): log silently-swallowed fetch failures (BUG-031 / #85)

`Modules/tasks/helpers/handleNotification.js:22-30` and the sibling at
line 33-43 had

    .catch(error => { return null })

β€” silently turning every DB failure (fetchProjectDetailsSingle /
fetchTaskDetails) into "no data" downstream. The notification flow
continued with projectData=null / taskData=null and emitted
notifications with missing context.

Keep best-effort semantics (notifications shouldn't fail user
operations), but log via Winston so the failure is visible in ops.

Closes #85

* fix(BUG-032): consistent soft-delete filtering on list/count endpoints

Three list-style endpoints either lacked a soft-delete filter or used
an over-narrow form that excluded legacy documents (where the flag was
unset):

* Modules/Comments/controller.js β€” `getPaginatedMessages` previously
  returned soft-deleted comments because it never matched on
  `isDeleted`. Added `{ isDeleted: { $ne: true } }` to the aggregation,
  matching `searchComments`. Also changed
  `searchMessageFromMainChat`'s `isDeleted: false` to
  `isDeleted: { $ne: true }` so legacy comments (no flag at all) keep
  showing up.

* Modules/Project/controller/getSprintFolder.js β€” the `count` branch
  matched on `projectId` only, so deleted sprints/folders inflated the
  sidebar counters. Aligned with the listing branches
  (`deletedStatusKey: { $nin: [1] }`).

* Modules/tasks/controller/getTabSyncTasks.js β€” `getTabSynctTaskWithTable`
  used `{ deletedStatusKey: { $in: [0] } }`, which dropped any task
  whose flag was unset. Sibling helpers (`getTaskCount`,
  `getTabSynctTaskWithoutTable` via plain `0`) need the legacy/undefined
  case to pass. Switched to `{ $in: [0, undefined] }` to bring it back
  in line.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(BUG-033): reconcile sprint task counts when secondary writes fail

The multi-step task flows in `Modules/tasks/helpers/mongo_helper.js`
(`moveTaskFunction`, `convertToListSubTask`, the merge-task path) all
follow the same shape: a primary findOneAndUpdate on a task, then one
or more independent `$inc` calls to keep the sprint's denormalised
`tasks` counter in sync. Each `$inc` is fire-and-forget β€” if it fails,
the count drifts away from the live tasks-collection state and the
sidebar shows the wrong number until someone notices.

A full MongoDB transaction would be the textbook fix, but
`MongoDbCrudOpration` does not thread a session through, and Mongo
transactions require a replica-set deployment that the self-hosted
footprint cannot guarantee. A self-healing reconcile is safer and
works in every deployment mode.

Changes:

- **Modules/tasks/helpers/reconcileTaskCount.js (new)** β€”
  `computeLiveTaskCount(companyId, sprintId)` recomputes the canonical
  count from the tasks collection (with the BUG-032 filter shape
  `deletedStatusKey ∈ {0, undefined}`).
  `reconcileSprintTaskCount(...)` writes the recomputed value back.
  `scheduleReconciliation(...)` runs it off the event loop via
  `setImmediate` so callers don't have to chain another promise.
  Helper is idempotent and best-effort: invalid input β†’ null, errors
  are logged but never rethrown.

- **Modules/tasks/helpers/mongo_helper.js** β€”
  Wire `scheduleReconciliation` into every `updateSprintFun(...).catch`
  in `moveTaskFunction`, `convertToListSubTask`, and the merge-task
  flow, plus the outer `findOneAndUpdate` catch in `moveTaskFunction`
  (where the source task is already soft-deleted before the destination
  insert fails). On failure, the count is recomputed from source-of-
  truth instead of being left drifted.

The audit's framing was slightly off β€” `HandleTask` itself only does a
save + history hooks, not the count updates. The actual drift risk
sits in the move/convert/merge flows downstream, which this commit
addresses.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(BUG-034): rotate Winston log files

`Config/loggerConfig.js` wired three plain `transports.File` instances,
so `log/track.log`, `log/error.log`, and `log/combined.log` grew without
bound. On long-running deployments this eventually fills the disk and
crashes the process.

Replace each with a `winston-daily-rotate-file` transport that:

* Splits files by date (legacy basename preserved as a prefix:
  `track-YYYY-MM-DD.log`, `error-…`, `combined-…`), so any existing
  tail/grep / log-aggregator wiring keeps working.
* Caps each file at 20MB (`maxSize`) β€” env-tunable via LOG_MAX_SIZE.
* Keeps the last 14 days of rotated files (`maxFiles`) β€” env-tunable
  via LOG_MAX_FILES.
* Gzips rotated files (`zippedArchive: true`) so old logs are cheap to
  retain.

Also adds `winston-daily-rotate-file ^5.0.0` to dependencies.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(BUG-035): pin cron jobs to a known timezone

`schedule.scheduleJob('0 0 * * *', …)` resolves the schedule in the
server's local timezone, so DST transitions or a container-host tz
change silently move the wall-clock firing time. Daily-midnight jobs
suddenly fire at 23:00 or 01:00 with no log trail.

Switch every `scheduleJob` call to the `{ rule, tz }` object form. The
tz defaults to UTC (the only zone that's stable everywhere), but
operators can override via the `CRON_TZ` env var when they need
calendar-day cuts in a specific region for accounting/billing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(BUG-036): path-traversal-safe loader for SERVICE_FILE

`Modules/checkinstallstep/controller.js` did
  require("../" + process.env.SERVICE_FILE)
and `Config/firebaseConfig.js` did
  require(path.resolve(__dirname, "..", config.SERVICE_FILE)).
Either lets an attacker (or a careless installer-wizard input) load
arbitrary JS/JSON inside the repo by stuffing `../`-style paths into
`SERVICE_FILE`. A `.js` file under the repo root would even be
executed with full process privileges.

Add `utils/safeServiceFile.js` exposing `resolveServiceFile()` which:
  * rejects absolute paths,
  * resolves the supplied path against the project root and verifies
    the result is still inside it (no `..` escape),
  * requires a `.json` extension (the Firebase service-account file
    is always JSON; this blocks the arbitrary-`.js` execution path),
  * verifies the file exists.

Wire the helper into both call sites so the same allow-list applies
whether SERVICE_FILE arrives via env at boot (`firebaseConfig.js`) or
during the installation wizard (`checkinstallstep/controller.js`).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(BUG-037): tighten global bodyParser limit

Every endpoint accepted a 50MB JSON / url-encoded / raw body via
the global `bodyParser` middlewares. Any unauthenticated POST could
force the server to buffer the full 50MB before validation ran β€”
trivial memory DoS.

Drop the global default to 2MB (well above typical JSON payloads:
comments, settings, project data β€” but blocks the multi-MB DoS).
The body-parser cap is independent of multer, so file-upload routes
keep working under their own multer limits.

Operators with bulk-import use cases that legitimately need bigger
JSON can raise the cap via the `BODY_LIMIT` env var without code
changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(BUG-038): drop S3 request handler timeout from 5 min to 30 s

`Config/config.js` built `requestHandler` with both `connectionTimeout`
and `socketTimeout` set to 300_000 ms (5 minutes). A hung Wasabi call
pinned the request worker for the full five minutes β€” a brief upstream
outage was enough to saturate the worker pool and degrade unrelated
routes.

Drop both defaults to 30_000 ms (30 s). That covers normal multi-MB
uploads on slow links but gives up quickly on truly stuck connections.
Env-tunable via `S3_CONNECTION_TIMEOUT_MS` / `S3_SOCKET_TIMEOUT_MS`
for operators who need a different ceiling (e.g. very large file
uploads from poorly-connected mobile clients).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(BUG-039): server-side identity verification for social login

The frontend completes the OAuth dance client-side and POSTs
`{email, googleId}` or `{email, githubId}` to `/loginAuth`. The
backend trusted those values blind, so anyone who knew a victim's
email plus their numeric provider id could authenticate as them. The
audit's framing ("missing state parameter") was off for this
architecture β€” there is no backend-initiated OAuth redirect to attach
state to β€” but the underlying threat is real.

Add server-side verification gated behind env config so existing
deployments don't break on upgrade.

GitHub:
* `verifyGithubAccessToken(accessToken)` round-trips
  `GET https://api.github.com/user` (HTTPS, 8 s timeout, structured
  User-Agent + Accept headers).
* `verifyGithubAuth` rejects login unless GitHub's response carries
  the same id and email as the client-supplied claim.
* Strict by default when an accessToken is present. Operators on
  legacy clients can keep the old behaviour by setting
  `GITHUB_OAUTH_REQUIRED=false` while they ship a frontend update.

Google:
* `verifyGoogleIdToken(idToken)` uses google-auth-library (already
  a transitive dep) to validate signature, audience, and expiry,
  then pulls the canonical `sub` and `email` from the verified
  payload.
* `verifyGoogleAuth` rejects login on sub or email mismatch.
* Gated on `GOOGLE_OAUTH_CLIENT_ID` (the audience). If unset we log a
  one-line warning and fall through to the legacy path so single-host
  upgrades stay safe; `GOOGLE_OAUTH_REQUIRED=false` keeps the
  legacy id-only behaviour when the client-id IS set but the client
  hasn't been updated yet.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(BUG-040): drop deprecated `atob` / `btoa` npm shims

Both packages have been runtime globals in Node since v16, so the npm
shims (`atob 2.1.2`, `btoa 1.2.1`) are dead weight in the lockfile and
haven't been maintained in years.

Replace the two call sites with a one-line `Buffer.from(...)`
expression that round-trips identically to what the shims did:

* Modules/auth/controller/verifyInvitation.js β€” decode base64 β†’
  binary string for the invitation-blob parser.
* Modules/auth/controller/sendInvitation.js β€” encode binary β†’
  base64 for the same blob construction.

`npm uninstall atob btoa` removes both packages from
package.json/package-lock.json.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(BUG-041): drop aws-sdk v2; consolidate on @aws-sdk/* v3

Both `aws-sdk` (v2, maintenance-only) and `@aws-sdk/*` (v3) were
installed and used in the same codebase. The only live consumer was
SES email sending in `Modules/servicewithAWS.js` (via
`Config/aws.js`'s exported `ses` client). The other v2 clients we
exported (`sesv2`, `ssmClient`, `s3`, `sesWithAttachment`) had zero
call sites β€” dead weight.

Migrate to @aws-sdk/client-ses (v3) and remove `aws-sdk` from
package.json.

Config/aws.js:
* Build the SES client via `new SESClient({...})` from
  `@aws-sdk/client-ses`.
* Expose `awsRef.ses` as a v2-shaped shim: `sendEmail(params, callback)`
  internally invokes `client.send(new SendEmailCommand(params))` and
  fires the callback with `(err, data)` so the single existing caller
  in servicewithAWS.js needs no rewrite.
* Also expose the raw v3 client as `awsRef.sesClient` for any future
  code that wants to use Commands directly.
* Drop the unused exports (sesv2, ssmClient, s3, sesWithAttachment).

Modules/servicewithAWS.js:
* Remove the duplicate `aws-sdk` import and `AWS.config.update()` call
  (v2-only).
* Switch the nodemailer `SES` transport to the v3-compatible
  `{ SES: { ses, aws: require('@aws-sdk/client-ses') } }` shape for
  the `sendAttachMail` path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(BUG-042): drop `moment` from backend; standardise on luxon

CLAUDE.md says the project standardised on Luxon. The backend still
imported `moment` in five files for trivial date formatting; this PR
removes those usages and drops `moment` from the root package.json.

Frontend `moment` usage (hundreds of call sites across Timesheet /
TimeLog / composables) is NOT touched here β€” that's a much larger
migration scoped separately. The frontend's own `package.json` keeps
its `moment` entry so the Vue build doesn't break.

New helper:
* `utils/dateHelpers.js` exposes
  - `formatDate(input, fmt)` β€” accepts JS Date, millis, ISO string,
    or `{seconds}` shape, and translates moment-style format tokens
    (YYYY/MM/DD/HH/mm/ss/A/MMM/ddd) to luxon-style under the hood
    so existing callers don't have to change their format strings.
  - `formatNotificationDate(input)` β€” exact replacement for the two
    `moment.calendar()` sites in `Modules/notification/sendEmail/`,
    which both used the same format string for every branch (so the
    relative-time wrapper was a no-op). Preserves the original
    output, including the pre-existing `HH:MM` token quirk.

Migrations:
* `Modules/logTime/controllerV2.js` β€” single `.format("YYYY-MM-DD")`
  call β†’ `formatDate(date, 'yyyy-LL-dd')`.
* `Modules/notification/sendEmail/controller.js` and
  `Modules/notification/sendEmail/controllerV2.js` β€”
  `moment().calendar(...)` β†’ `formatNotificationDate(...)`.
* `Modules/tasks/helpers/helper.js` and
  `Modules/tasks/helpers/mongo_helper.js` β€” `changeDateFormat`
  delegates to `formatDate` (which keeps the moment-style API its
  callers pass).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(BUG-043): wrap clickable <span>/<img> affordances in real <button>

Two affordances dispatched click handlers on non-semantic elements
(no role, no aria-label, no keyboard focus, no screen-reader
announcement):

* `frontend/src/components/atom/Modal/Modal.vue` β€” the modal close
  icon was a bare `<img @click="closeModal()">`. Wrap in
  `<button type="button" :aria-label="$t('Projects.close') || 'Close'">`
  with the icon inside as a presentational `<img alt="">`.
* `frontend/src/components/atom/Attachments/Attachments.vue` β€” the
  "Download All" affordance was a `<span @click="downloadAllImages()">`.
  Replace with `<button type="button" class="download-all-btn ...">`.

Both components' stylesheets gain a tiny rule to strip native button
chrome (so the visual result matches the old elements) but preserve
`:focus-visible` so keyboard users see the focused state.

Note: a couple of nearby affordances (Attachments' "See All" `<div>`
and the help-icon popover) have the same anti-pattern but are out of
scope here β€” the audit specifically flagged the `<span>` and `<img>`
cases above.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(BUG-044): give Modal role=dialog, aria-modal, and focus trap

The Modal atom rendered a bare `<div class="modal">` β€” no role,
no aria-modal, no aria-labelledby. Screen readers couldn't announce
the modal as a dialog and focus could leave the modal via Tab and
reach background controls.

Changes to `frontend/src/components/atom/Modal/Modal.vue`:

* Template:
  - Root `<div class="modal">` gains `role="dialog"`,
    `aria-modal="true"`, `:aria-labelledby="titleId"`, and
    `tabindex="-1"` (so focus can land on the dialog itself when no
    children are focusable).
  - The title `<span>` now carries the `:id="titleId"` that
    aria-labelledby points at.
  - `@keydown.tab="handleTabKeydown"` traps Tab; `@keydown.esc.stop`
    closes the modal on Escape.

* Script:
  - `modalRef` template ref + `titleId` computed.
  - `handleTabKeydown` cycles focus between the first and last
    focusable descendants (queries the standard focusable selector
    list, skipping aria-hidden and offscreen elements).
  - `activateFocusTrap` captures the previously-focused element and
    moves focus to the first focusable inside the modal (or the
    dialog itself if there are none). Called on mount-if-open and
    when `modelValue` flips to true.
  - `deactivateFocusTrap` restores focus to the captured element on
    close / unmount.

No new dependency: the trap is ~40 lines of inline logic; we don't
need `focus-trap` for one component.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(BUG-045): wire up the jest test suite

`npm test` was a placeholder (`echo "Error: no test specified" && exit 1`)
even though jest was already installed as a devDependency. This PR
wires it up as a working bootstrap suite that future fixes can grow
into.

Changes:

* `package.json` β€”
  - `test` now runs `jest`.
  - new `test:watch` (development) and `test:naming` (the pre-existing
    structural-audit test, kept off the default run because it flags
    legacy naming inconsistencies tracked separately).
* `jest.config.js` β€” points jest at `tests/`, ignores
  node_modules/frontend/installation/time-tracker-app/.claude and the
  naming-conventions audit, and runs in `node` environment.
* `tests/smoke.test.js` β€” minimal guarantee the harness is alive
  (always runs).
* `tests/utils/dateHelpers.test.js`, `tests/utils/safeServiceFile.test.js`,
  `tests/utils/imageGuard.test.js` β€” behaviour tests for the helpers
  introduced earlier in this audit (BUG-042, BUG-036, BUG-023). Each
  suite is wrapped in a graceful skip when its target module isn't on
  the current branch, so this PR lands cleanly today and the tests
  light up automatically once the corresponding helper PRs merge to
  staging.

`npm test` exits 0 β€” 3 passed (smoke) + 16 skipped (helpers pending
merge). After BUG-023/036/042 merge, all 19 should pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(BUG-046): let Mongoose manage history timestamps

`Modules/tasks/helpers/{helper,mongo_helper}.js`'s `HandleHistory`
wrote `createdAt` / `updatedAt` manually using `DateTime.utc().ts`,
which is a numeric millisecond value β€” not a BSON Date. Existing
documents ended up with inconsistent shapes (number vs Date) depending
on which code path created them.

Every other schema in `utils/mongo-handler/createSchema.js` is built
with `{ timestamps: true }` so Mongoose owns the timestamps. The
history schema alone was missing that option.

Changes:

* `utils/mongo-handler/createSchema.js` β€” add
  `{ strict: false, timestamps: true }` to `historySchema`.
* `Modules/tasks/helpers/mongo_helper.js` (HandleHistory) and
  `Modules/tasks/helpers/helper.js` (HandleHistory) β€” drop the manual
  `createdAt: utcDateTime.ts, updatedAt: utcDateTime.ts` lines.
  Mongoose populates both as BSON Dates on `.save()`.
* `utils/mongo-handler/schema.js` β€” keep `createdAt`/`updatedAt`
  declared on the history shape so callers that read them still work,
  but drop `required: true`. Mongoose sets them on `.save()`; making
  them required on updates would reject legacy docs that never had
  them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(BUG-047): ARIA semantics + keyboard nav for CustomDropDown

`frontend/src/components/molecules/DropDown/CustomDropDown.vue` is the
shared custom dropdown atom used across the app. Pre-fix:

* The trigger `<div @click="buttonClick()">` had no role,
  no aria-haspopup, no aria-expanded, no tabindex β€” keyboard users
  couldn't reach or open it.
* The floating panel had no role="listbox" β€” screen readers couldn't
  identify it as a menu.
* The mobile-view close affordance was a bare clickable `<img>`.

Changes:

* Trigger gains role="button", tabindex="0", :aria-haspopup="'listbox'",
  :aria-expanded (bound to open state), :aria-controls (panel id).
* Trigger handles keyboard:
  - Enter / Space β€” open/close (mirrors click).
  - Escape β€” close.
  - ArrowDown β€” open AND move focus to the first focusable child
    inside the options slot. (We can't enforce role="option" on
    user-provided slot content, but moving focus there gets keyboard
    users navigating immediately.)
* Floating panel gains role="listbox" and an @keydown.esc handler.
* Mobile close `<img>` wrapped in a real `<button type="button">`
  with aria-label="Close" and a style rule (`.dropdown-close-btn`)
  that strips native chrome but preserves :focus-visible.
* New helpers: `closeDropdown()` and `openAndFocusFirstOption()`.

Note: the sibling `DropDown.vue` and `MobileDropDown.vue` follow the
same anti-pattern but were not flagged by the audit; they'd benefit
from a similar sweep in a follow-up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Done - Test Case

Done - Test Case

* Add Gitigonre

Add Gitigonre

* Rename the folder Auth

Rename the folder Auth

* Rename Folder

Rename Folder

* Chnage the name

Chnage the name

* Folder Rename

Folder Rename

* remove dist

remove dist

* Create yml

Create yml

* Update main.yml

* Update main.yml

* Update main.yml

* Basic Setup

Basic Setup

* fix: repair broken advisory URL in SECURITY.md (#44)

The advisory link was split across two lines and wrapped in a code span,
rendering as malformed text rather than a clickable link. Joined the URL
and used proper markdown link syntax. Pairs with enabling Private
Vulnerability Reporting in repo settings so external reporters can
actually submit advisories.

Closes #44

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: refresh correct lastRequest field on connection reuse (#42)

updateConnectionRecord was writing to lastRequset (typo) while the
idle-cleanup loop in startInterval reads lastRequest. Active company
databases therefore kept the timestamp frozen at createdAt and were
eligible for termination after the 30-minute window despite continuous
use. Fixed the property name so the update path and the cleanup path
reference the same field.

Closes #42

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: remove duplicate CheckInstallStep mount and .env hot-reload (#41)

Two related sources of duplicate-handler accumulation in the bootstrap:

1. CheckInstallStep was mounted twice: once inside
   initializeControllers() and once unconditionally at the top level.
   The top-level mount has to stay because the install wizard must be
   reachable before MONGODB_URL is configured. Removed the inner mount
   so each startup registers it exactly once.

2. fs.watchFile on .env called initializeControllers() on every save,
   which re-ran .init(app) for ~60 route modules onto the same Express
   app instance (Express has no clean route un-registration) and spun
   another setInterval inside startInterval(). Removed the watcher;
   nodemon already restarts on file changes in dev, and production
   env changes require a process restart anyway.

After this change initializeControllers() is only invoked once, from
the MONGODB_URL startup gate.

Closes #41

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: preserve dataType and options across JWT refresh retry (#38)

apiRequest and apiRequestWithoutCompnay reconstruct the call after a
token refresh using only (type, endPoint, data), dropping dataType
and options. Any retry of a multipart upload then runs through the
JSON axios instance instead of the form-data instance, and any
caller-supplied abort signal or per-request option is lost. Forwarded
dataType and options into both retry calls so the replay matches the
original.

Closes #38

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: clean up close_click IPC listener after each notification (#39)

sendNotification() registered a new ipcMain.on('close_click', ...)
handler on every screenshot capture and never removed it, so the
process accumulated one global IPC listener per screenshot and a
single close click eventually fired N stale handlers, each trying to
close an already-destroyed BrowserWindow reference.

Switched the registration to ipcMain.once so the happy path
(user clicks close) auto-removes the listener. For the auto-timeout
path (window closes after 10s without a click) the handler stays
registered and would later steal a future notification's close
event, so also remove it explicitly when the window emits 'closed'.
The timeout id is now tracked and cleared on the same hook so the
timer can't fire against a destroyed window. The window reference
is captured in a local so each listener targets its own window even
if a subsequent sendNotification() reassigns the module-level
screenshotNotificationWindow before this listener fires.

Closes #39

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: allow editing the "Created by" user on a task

The task sidebar Details panel previously showed Created by (the
Task_Leader field) as a read-only user β€” set once at creation and
never changeable. Add an inline picker mirroring the existing
Assignee field so the creator can be reassigned.

Changes:
- Backend: new updateTaskLeader action on task_class_Mongo. Validates
  the new Task_Leader, writes via $set, emits the same
  socketEmitter.emit('update', { module: 'task', updatedFields }) used
  by updateAssignee, and logs a TaskLeader_Changed history entry via
  HandleHistory. Includes the isUpdateTask:false side-effects-only
  branch for parity with updateStatus / updatePriority.
- Frontend store (TaskOperations): new updateTaskLeader action that
  optimistically commits the new Task_Leader into the projectData
  Vuex store, then PATCHes /api/v2/tasks with action 'updateTaskLeader'.
- TaskDetailRightSide.vue: replaced the read-only Created-by block
  with an Assignee picker (single-select) for users holding the
  task.task_assignee + task.task_list permissions, preserving the
  original read-only display as the fallback for users without those
  permissions. Wired @selected to a new updateTaskLeader() handler
  with the same toast / error pattern as updateAssignee.
- i18n: added Toast.Created_by_updated_successfully and
  Toast.Created_by_not_updated to the English locale. The other 10
  locales fall back to English via vue-i18n until translators
  backfill β€” flagged as a follow-up.

Permission policy: reuses task.task_assignee. Anyone allowed to
change the Assignee can also change Created by. No new permission
key, no role-permission migration. Backend has no per-field gate,
consistent with the existing Assignee / Status / Priority update
paths which all trust the frontend permission gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: split header notifications into Unread and Archive views

Previously the notification bell fetched every notification the user
had ever received (read or unread) in a single combined list, making
it hard to focus on actionable items. This change splits the dropdown
into two views β€” Unread (default on every open) and Archive β€” gated
by a single toggle button in the dropdown head.

Changes:
- Backend: GET /api/v1/app-notification/notification accepts a new
  optional `filter` query param ('unread' | 'archived'). 'unread' adds
  `notSeen: { $in: [userId] }` to the aggregation match; 'archived'
  adds `notSeen: { $nin: [userId] }`. Default is 'unread' to keep the
  bell focused on actionable items. Refactored the match clauses into
  a `baseMatch` array so the filter is appended cleanly; query shape
  and pagination are otherwise unchanged.
- Header.vue: added a `notificationFilter` ref ('unread' by default),
  a "View Archive" / "View Unread" toggle in the dropdown head, a
  `switchNotificationFilter()` handler that resets paging state and
  refetches, and an `openNotificationsDropdown()` helper that the
  bell click handlers now use so each fresh dropdown open lands in
  Unread. `markRead()` and `markAllRead()` clear the read items from
  the local list while the Unread filter is active so the dropdown
  reflects the filter without an extra refetch. The "Mark all as
  read" button visibility now keys off `notifications.length` (the
  list is already filtered to unread on this view) rather than the
  server-side `totalNotification` counter, which can lag and falsely
  hide the button.
- i18n: added Header.View_Archive / Header.View_Unread to the English
  locale; other locales fall back to English via vue-i18n until
  translators backfill (flagged as a follow-up).

Out of scope (deferred per user instruction): goals 3 and 4 from the
original spec β€” creator-prefs fire policy verification and email
preference parity audit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: honour project-level Ignore for task creator and company owner

Closes goals 3 & 4 of the notification refactor. Two parallel bypasses
in handleNotification.HandleBothNotification let task creators and
company owners through the project-level watcher filter (the Ignore /
All Activity / Participating setting in the List of Watcher panel):

- Task branch (taskId path): Task_Leader was Set-union'd into the
  recipient list AFTER `projectData.watchers` had already filtered out
  any user set to "ignore". So a creator who had set the project to
  Ignore still ended up in `assigneeUsers`, and the downstream
  per-event preference check (NOTIFICATIONS_SETTINGS β€” a separate
  preference layer that defaults email=true) emitted both an in-app
  and an email notification. Now Task_Leader is included only if their
  project-watcher setting is not "ignore".

- Project branch (type === 'project'): same pattern β€” companyOwnerId
  was union'd in unconditionally, bypassing the watcher filter.
  Honours "ignore" too.

This explains the goal-4 symptom of "I'm getting emails for events I
disabled": the disablement was set at the project-watcher layer, but
the bypass routed the recipient straight to the per-event layer where
email was still on.

Other recipient paths are untouched. Default behaviour for users who
have not chosen "ignore" is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: per-company screenshot retention policy + nightly cleanup cron

Adds an opt-in retention policy on the time-tracker screenshot pipeline.
Visible on /settings/setting to the company owner (roleType === 1)
only. When enabled, a nightly cron permanently deletes trackshots
older than the configured window (3 / 6 / 12 / 24 months) from both
the per-tenant TimeSheet.trackShots subdoc array and from Wasabi
(main object + every thumbnail variant).

Backend
=======
- utils/mongo-handler/schema.js: new `screenshotRetention` Map field
  on the global companies schema. Stores `enabled`, `maxAgeMonths`,
  `enabledAt`, `enabledBy`, `lastRunAt`, `lastRunStats`,
  `firstRunCompletedAt`, `runningSince`. Backward-compatible β€” legacy
  companies without the field read as `enabled: false`.

- Modules/ScreenshotRetention/: new module.
    helper.js   β€” policy read/write, preview counter, per-company
                  cleanup workflow, and the cron entry point.
                  Production-ready guarantees:
                    * Wasabi delete BEFORE db $pull so transient
                      Wasabi failures leave the db record intact and
                      the next nightly run retries (no permanent
                      orphans).
                    * Per-trackshot main + 4 thumbnail keys deleted
                      (sizes hard-coded from thumbnail.json).
                    * Filters by trackshot.screenShotTime (epoch ms),
                      not parent TimeSheet timestamp.
                    * Advisory `runningSince` lock prevents
                      double-runs; stale locks (>4h) are reclaimed.
                    * First-run safety cap (50k deletions) for the
                      initial cleanup on legacy data; lifted once
                      `firstRunCompletedAt` is stamped.
                    * Bounded company concurrency (5 in parallel)
                      via Promise.allSettled.
    controller.js β€” three endpoints with owner role check:
                    GET  /api/v1/screenshot-retention
                    GET  /api/v1/screenshot-retention/preview
                    PUT  /api/v1/screenshot-retention
                    Owner check looks up the per-tenant
                    `company_users` doc for the caller and confirms
                    `roleType === 1`. Returns 403 on mismatch.
    routes.js     β€” endpoint registration.
    init.js       β€” module bootstrap (matches existing convention).

- index.js: register the new module beside the rest of
  `initializeControllers()`.

- cron.js: removed the broken `cleanUpTrackshot()` call (was
  referencing an unimported binding and never executed). Added a new
  schedule at 00:30 UTC that invokes
  `screenshotRetention.runRetentionForAllCompanies()`. Off-peak vs the
  other midnight jobs so a heavy cleanup doesn't compound with the
  bucket-size + AI-reset jobs on the same minute.

Frontend
========
- frontend/src/components/molecules/Setting/SettingScreenshotRetention.vue:
  new card mounted on /settings/setting. Renders only for
  `companyUser.roleType === 1`. Toggle + retention-window dropdown +
  last-run telemetry. Enabling fires a SweetAlert confirmation that
  shows the preview-count from the new GET preview endpoint so the
  owner knows exactly what will be deleted on the next nightly run.

- frontend/src/views/Settings/Setting/Setting.vue: mount the new
  component in the existing right-hand column. Self-hides for
  non-owners.

- frontend/src/locales/en.js: new ScreenshotRetention.* keys (heading,
  toggle/window labels, confirmation copy, last-run telemetry). Other
  locales fall back to English via vue-i18n.

Out of scope (deferred)
=======================
- Translation backfill for non-English locales.
- A `RetentionAuditLog` collection for per-run history beyond
  `lastRunStats`. The cron emits structured logs in the meantime.
- A "dry run" mode that lets the owner see what would be deleted
  without enabling the policy. The preview endpoint already gives the
  count; a per-record preview would need its own UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: comment out unused companyId injection in SettingScreenshotRetention component

* feat(setup): one-command developer setup via `npm run setup`

Adds an additive setup orchestrator that installs deps, builds the wizard,
bootstraps .env, starts backend + frontend, completes the installation wizard
headlessly, creates a default admin account, and opens the login page β€” all
from a single command. Non-technical contributors can go from `git clone` to
a working login screen with no manual steps.

New
- scripts/dev.js β€” orchestrator with HTTP-based wizard auto-completion,
  MongoDB probe with retry, defensive .env patching, credentials banner.
- nodemon.json β€” explicit watch list (server-side dirs / .js only) so wizard
  writes to installationSteps.json no longer restart the backend mid-request
  (root cause of the "wizard reloads on MongoDB step" bug).
- package.json β€” `setup`, `dev`, `setup:reset` scripts + nodemon devDep.

Wizard improvements (back-compat preserving)
- Modules/CheckInstallStep/controller.js: `isDoItLater` support added for
  Firebase (step 4) and SMTP (step 6), mirroring the existing AI (step 5)
  skip pattern. Both steps remain mandatory unless the caller opts in.
- Defensive APIURL fallback at module-load (was crashing the backend on
  fresh clones if .env hadn't loaded yet β€” TypeError on .substring of
  undefined).

.env.example
- SERVICE_FILE corrected from "../firebase-adminsdk.json" to
  "./firebase-adminsdk.json" (the prior default tripped the BUG-036
  path-traversal guard and blocked the Firebase wizard step).
- Quick-start comment block at the top.

Nothing existing changes
- `npm start`, `npm run nodemon`, `npm run basic-install`, the interactive
  wizard, and the documented manual setup paths are all untouched.
- New scripts never run automatically; users must invoke them explicitly.
- Fallback chain at every failure point (MongoDB unreachable / auto-setup
  error / --manual flag) opens the interactive wizard UI so the user is
  never left in an unrecoverable state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(screenshot-retention): security + correctness pass on PR #157

Independent review of the original commit surfaced three blockers and
several correctness issues. This is a follow-up commit on the same
branch that addresses all of them.

BLOCKERS
========

1. **Routes had no JWT middleware.** Config/setMiddleware.js gates auth
   via path allowlists. The new `/api/v1/screenshot-retention` and
   `/api/v1/screenshot-retention/preview` paths were NOT listed in
   either `verifyJWTTokenWithCRoute` or `verifyJWTToken`, so the
   endpoints shipped completely unauthenticated β€” anyone on the
   internet could toggle retention for any company. Added both paths
   to `verifyJWTTokenWithCRoute` so the JWT middleware fires and
   populates `req.uid` / `req.aud`.

2. **Thumbnail keys were generated in the wrong dimension order.** The
   upload-time call at Modules/storage/wasabi/controller.js:298 passes
   `(thu.height, thu.width)` into a function whose params are
   `(width, height)`, so the stored filename is effectively
   `<base>-<height>x<width>.<ext>`. The cleanup helper was building
   `<base>-<width>x<height>.<ext>` and missing every thumbnail variant
   on every retention run β€” leaving four orphan Wasabi objects per
   trackshot, permanently. derivThumbnailKeys now mirrors the
   upload-time swap with a comment explaining the pre-existing bug
   in the upload path (out of scope to fix here).

3. **Cursor-based pagination replaces skip-based scan.** The previous
   loop advanced `pagedSkip += docs.length` after each batch, but
   the `$pull` inside the loop removes docs from the match set β€”
   meaning the next page's skip lands past where it should. Most of
   the DB was being missed on legacy-data tenants. Replaced with
   `_id > lastSeenId` cursor that's immune to in-flight match-set
   shrinkage.

SECURITY
========

4. **Controller now reads userId from `req.uid`** (set by
   Config/jwt.js#checkToken from the verified token's `uid` claim),
   not from `req.body.userId`. The old code let a non-owner pass the
   owner's userId in the body and pass the role check. Status codes
   updated: missing userId β†’ 401 (authentication required) rather
   than 400.

HIGH
====

5. **Legacy string-typed `screenShotTime` is now handled.** Multipart
   form uploads coerce numbers to strings at write time, so the
   `screenShotTime` field on legacy trackshots is stored as a string.
   The previous `$lt: cutoffMs` (number) didn't match strings.
   - `countOldTrackshots` now uses `$convert` with
     `to: 'long', onError: null` inside the $filter so the comparison
     works regardless of the field's actual storage type.
   - `runRetentionForCompany`'s loose DB query + strict in-memory
     filter coerces via `Number(t.screenShotTime)` and skips
     non-finite values.

6. **`firstRunCompletedAt` is now stamped only when a real cleanup
   completed.** Previous condition `deletedCount < cap` stamped the
   marker on a zero-deletion run (e.g. a new company with no eligible
   data), which lifted the first-run safety cap before any real bulk
   data accumulated. New condition: scan exhausted (no cap hit, no
   error) AND deletedCount > 0.

7. **Empty-image trackshots no longer inflate `deletedCount`.** The
   scan loop now pre-filters them out (`oldShots` requires `t.image`),
   and `deleteTrackshotObjects` returns `skipped: true` rather than
   `mainDeleted: true` if it's ever called with a missing key.
   Stats now track `skippedCount` separately.

MEDIUM
======

8. **Removed the no-op `updateCompanyPolicy(companyId, {}, {})`
   call** that hit the empty-patch early-return at line 176 without
   writing anything. Lock acquisition is a single `stampMasterField`
   call, which is at least document-atomic. Cluster-aware leader
   election is still out of scope (documented).

9. **`lastRunStats` now records partial-run errors and cap-hit state.**
   New stats fields: `skippedCount`, `hitCap`, `error`. Ops can tell
   from the master doc whether the run completed cleanly, hit the
   cap, or threw mid-loop.

10. **Frontend now uses `watch(isOwner, …, {immediate: true})`** instead
    of one-shot `onMounted`. The Vuex `companyUserDetail` store
    sometimes hydrates after the component mounts on soft route
    changes; the one-shot mount would never call `loadPolicy()` in
    that case and the card stayed on hard-coded defaults forever.

LOW
===

11. **`extractKey` regex tightened** from `[a-f0-9]{20,}` to
    `[a-f0-9]{24}` (exact ObjectId length) and now decodes URL
    path segments so percent-encoded keys map back to their
    canonical storage form.

12. **`getS3Client` doc + body cleaned up.** `s3Client` is not exported
    from `wasabi/controller.js`, so the old "fall back if not
    available" branch was always taken. Removed the misleading
    primary path; we just build the client from `awsRef` and
    memoise it.

Test plan addendum
==================
- Hit `GET /api/v1/screenshot-retention` without a JWT β†’ 401.
- Hit `PUT /api/v1/screenshot-retention` with a forged body
  `userId` of the owner from a non-owner JWT β†’ 403 (controller
  uses `req.uid`, not body).
- On a tenant with legacy string-typed `screenShotTime`:
  - GET /preview returns a non-zero count for the configured window.
  - A nightly run deletes those records + their (heightΓ—width-named)
    thumbnail variants.
- Confirm thumbnail keys deleted in Wasabi by listing the bucket
  after a run β€” there should be no orphans for the deleted main
  keys.
- Confirm pagination handles a tenant whose every TimeSheet has at
  least one expired trackshot (all docs drop from match set on
  first $pull): the cursor variant should still terminate cleanly
  and process every doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(security): P0 + partial P1 hardening, CI pipeline, multer v2

Closes the critical security and dependency gaps surfaced in the
codebase audit. All 20 unit tests pass; no behavior change for normal
request paths.

Backend security
- Sanitize regex search inputs across 7 controllers (AI,
  AdvancedGlobalFilter, Comments, MediaFiles, Project filter,
  notification-count, trackerDownload) via new utils/escapeRegex.js.
  Blocks NoSQL regex injection / ReDoS / cross-tenant name enumeration.
- Add helmet + global express-rate-limit in index.js. CSP disabled to
  preserve the Vue inline-script setup; secure / sameSite already
  env-gated correctly. trust-proxy set to 'loopback' to silence the
  ERR_ERL_UNEXPECTED_X_FORWARDED_FOR warning behind a same-host
  reverse proxy.
- Harden all 4 multer call sites via new utils/uploadConfig.js
  (DEFAULT_LIMITS, safeFileFilter blocking executable extensions,
  safeRelativePath for traversal protection). bucket.helper.js
  storage destination now validates path before writing.
- New requireCompanyAud middleware (Config/jwt.js) enforces the JWT
  `aud` claim against any companyId in body / params / query /
  header on the ~50 verifyJWTTokenV2-only routes. Non-ObjectId
  values (e.g. USER_PROFILES global bucket) pass through.

Dependencies
- Remove aws-sdk v2 from package.json (EOL Sept 2025); only @aws-sdk
  v3 was actually imported.
- Bump multer 1.4.5-lts.1 -> 2.1.1 to clear known 1.x CVEs.

Schema strictness (P1-SEC-11)
- utils/mongo-handler/createSchema.js: flipped 7 core schemas to
  `strict: true` (tasks, comments, timesheet, history, adminDetail,
  subscriptionPlan, globalCustomFields). Kept `strict: false` on 7
  intentionally-dynamic schemas (notification counters, Chargebee
  webhook mirrors, custom-field definitions, plan-feature maps) with
  inline comments explaining why.

CI / DevOps
- .github/workflows/main.yml: added a `validate` job (npm test, npm
  audit advisory, frontend install + build) that the deploy job now
  `needs:`. Previously deploys ran with zero validation.

Diagnostics
- modules/storage/wasabi/controller.js: replaced 6 generic
  `Error while upload file: ${error}` rejects with a
  formatS3UploadError helper that logs the AWS error Code, HTTP
  status, bucket, key, and requestId.

Documented for follow-up
- Auth/controller.js: TODO comments at both res.cookie sites
  explaining why httpOnly stays false until the frontend stops
  reading the cookie via js-cookie (P1-SEC-09 deferred).

Pre-existing fixes pulled in
- tests/utils/imageGuard.test.js: corrected env var names so the
  suite goes from 19/20 to 20/20 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(safeServiceFile): reject Windows absolute paths on any host

The CI pipeline added in the same PR is the first thing to run
tests on Ubuntu. `path.isAbsolute('C:\Windows\...')` returns
false on Linux, so the safeServiceFile absolute-path guard fell
through and the request hit the `.json` extension check instead.
The test in `tests/utils/safeServiceFile.test.js:46` expects the
"relative" error in both shapes and was passing on Windows hosts
by accident.

Use `path.win32.isAbsolute(...)` alongside the platform-bound
`path.isAbsolute(...)` so both POSIX (`/etc/hosts`) and Windows
(`C:\Windows\...`) absolute paths are rejected regardless of
which OS the process runs on. This is also a defensive improvement
to BUG-036: a Linux deployment can no longer be coaxed into
re-resolving a Windows-shaped path inside the project root.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Update main.yml

* Update main.yml

* feat(ai-project-generator): one-shot AI project bootstrap with PDF brief + SSE progress

Adds a parallel "Create with AI" path to the existing manual project wizard.
The user describes their project in natural language (optionally attaches a
PDF/DOCX/MD/TXT brief and chooses public/private workspace + a target task
count), the LLM returns a plan (project metadata + folders β†’ sprints β†’
tasks with rich descriptions), the user reviews/edits names, and on
"Create everything" the orchestrator commits the whole hierarchy via the
same write paths the manual flow uses β€” with SSE progress events and
rollback on partial failure.

== Backend ==
- New module Modules/AIProjectGenerator with:
  * llmProvider/ β€” env-selected adapter (openai | anthropic). Reads
    AI_API_KEY/AI_MODEL (existing) for OpenAI and ANTHROPIC_API_KEY/
    ANTHROPIC_MODEL (new) for Anthropic, picked via LLM_PROVIDER.
  * briefExtractor.js β€” multer v2 disk-buffer + pdf-parse + mammoth, 10MB
    cap, mime allow-list, control-char strip, 100k char truncation.
  * schemaValidator.js β€” zod PlanSchema with strict task/status/folder
    rules; sanitizes member ids against the active company roster.
  * promptTemplates.js β€” system + user + repair prompts. Forces 4-8
    tasks per sprint, domain-specific status names, full lifecycle coverage.
  * orchestrator.js β€” sequential project β†’ folders β†’ sprints β†’ bulk tasks.
    Reserves a taskKey range atomically, emits SSE per step, soft-rollbacks
    in reverse on any failure (tasks β†’ sprints β†’ folders β†’ project +
    projectCount decrement).
  * sseEmitter.js β€” heartbeat-equipped SSE channel keyed by random jobId.
  * Endpoints: /api/v1/ai/project/{upload-brief,plan,clarify,execute}
    + unauthenticated /api/v1/ai-progress/:jobId (jobId is a bearer
    capability β€” same pattern as /api/v1/generatePrompt/events).
- Config/setMiddleware.js: protected new auth paths via
  verifyJWTTokenWithCRoute; SSE endpoint deliberately omitted.
- index.js: register the new module after Modules/AI.
- .env.example: LLM_PROVIDER, ANTHROPIC_API_KEY, ANTHROPIC_MODEL,
  LLM_MAX_TOKENS_PLAN, LLM_MAX_TOKENS_CLARIFY.
- package.json: +@anthropic-ai/sdk, +pdf-parse, +mammoth, +zod.

== Frontend ==
- New AiProjectCreator.vue (organism) β€” 3-step sidebar:
  1. Describe: textarea (20-char minimum), public/private workspace toggle
     (mirrors manual ProjectWorkspace step), target-task-count slider,
     PDF/DOCX/TXT/MD upload, inline clarification Q&A.
  2. Review plan: project icon/code/description preview + inline-editable
     names at every level (project / folder / sprint / task), expandable
     task descriptions.
  3. Execute: live SSE progress UI (project β†’ folders β†’ sprints β†’ tasks)
     with rollback-aware error state and "Open project" CTA on complete.
  Close affordance is a close icon (was a Cancel button) β€” disabled
  while uploading/loading; the sidebar can't be dismissed during the
  execute phase to prevent orphaned in-flight jobs.
- New aiProjectGenerator.js composable wraps the four endpoints +
  EventSource subscription.
- Projects.vue / ProjectListComponent.vue / ProjectListing.vue add the
  "✨ Create with AI" button beside "+ New Project", gated on
  currentCompany.planFeature.aiPermission.
- env.js: 5 new endpoint constants.

== Highlights from QA pass ==
- projectIcon is persisted in the canonical {type:'color', data:'#hex'}
  shape Item.vue expects, so the AI-bootstrapped project's color/initial
  pill renders in the sidebar (was an empty box).
- Multer rejection (LIMIT_FILE_SIZE, unsupported mime, etc.) is caught
  before Express's default handler so users see "File is too large.
  Maximum allowed size is 10 MB." instead of "Request failed with status
  code 500".
- The user's explicit public/private choice in step 1 is forced onto the
  plan server-side at /plan, /clarify and /execute β€” it always wins over
  whatever the LLM emitted.
- Plan-feature quota (projectCount.*) is incremented like the manual
  flow but the AI path intentionally skips checkProjectPlan's hard cap
  so this feature remains usable on plans that limit project counts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci(main.yml): map VUE_APP_* secrets into the frontend build step

The CI frontend build was running with no .env present (gitignored, not
materialised on the runner) so every `process.env.VUE_APP_*` reference
was inlined as `undefined` in the bundle. Map the Firebase keys, storage
config, support-routing ids, and OAuth feature flags as repository
secrets in the Build frontend step so the validation build matches what
production actually ships.

Also bumps Node to 22, switches to npm ci, adds concurrency cancellation
for staging deploys, and tightens the workflow trigger list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(env): update AI model to gpt-4o-mini in .env.example

* refactor(projects): split Projects.vue and Task.vue mega-components (#167)

Projects.vue: 2,737 -> 1,365 lines (50% reduction). Task.vue: 1,015 -> 540 lines (47% reduction).

- 9 composables extracted (useProjectCalendar, useProjectAvatar, useEmbedViews,
  useProjectLifecycle, useProjectAssignee, useProjectTour, useProjectSearch,
  useProjectRules, useProjectNameEdit)
- 5 sub-components extracted (ProjectActionsBar, ProjectFiltersToolbar,
  ProjectSidebars, ProjectBottomModals, ProjectEmptyState)
- 7 view components (ListView, Comments, ActivityLog, WorkloadView, BoardView,
  ProjectDetail, TableView, EmbedViewItem) converted to defineAsyncComponent
  so each tab pulls its own chunk on demand
- 2 task composables (useTaskMutations, useTaskActions) and 1 sub-component
  (TaskQuickMenu) extracted; duplicate mobile/desktop quick-menu markup deduped

Bundle (npm run build): main project chunk 2,621,285 -> 2,047,548 bytes
(-22%, -560 KB). New lazy chunks: project-list-view 381 KB, project-detail
117 KB, project-kanban 42 KB, project-table-view 28 KB, project-workload
17 KB, embed-view 3 KB, project-activity-log 0.5 KB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(backend): split task/logtime/milestone/auth mega-files (#164)

Splits four oversized source files into focused sub-modules so no file in
the affected modules exceeds ~640 lines (issue target: ~800). Public API
is preserved: each original module path keeps re-exporting every symbol
it used to, so routes.js and downstream consumers are untouched.

- Modules/Tasks/helpers/task_class_Mongo.js (3,129 -> 27 lines)
  Mixin pattern under taskMongo/: create, updateBasic, updateAssignment,
  updateMe…
joshishiv4 added a commit that referenced this pull request Jun 19, 2026
* fix(BUG-042): drop `moment` from backend; standardise on luxon

CLAUDE.md says the project standardised on Luxon. The backend still
imported `moment` in five files for trivial date formatting; this PR
removes those usages and drops `moment` from the root package.json.

Frontend `moment` usage (hundreds of call sites across Timesheet /
TimeLog / composables) is NOT touched here β€” that's a much larger
migration scoped separately. The frontend's own `package.json` keeps
its `moment` entry so the Vue build doesn't break.

New helper:
* `utils/dateHelpers.js` exposes
  - `formatDate(input, fmt)` β€” accepts JS Date, millis, ISO string,
    or `{seconds}` shape, and translates moment-style format tokens
    (YYYY/MM/DD/HH/mm/ss/A/MMM/ddd) to luxon-style under the hood
    so existing callers don't have to change their format strings.
  - `formatNotificationDate(input)` β€” exact replacement for the two
    `moment.calendar()` sites in `Modules/notification/sendEmail/`,
    which both used the same format string for every branch (so the
    relative-time wrapper was a no-op). Preserves the original
    output, including the pre-existing `HH:MM` token quirk.

Migrations:
* `Modules/logTime/controllerV2.js` β€” single `.format("YYYY-MM-DD")`
  call β†’ `formatDate(date, 'yyyy-LL-dd')`.
* `Modules/notification/sendEmail/controller.js` and
  `Modules/notification/sendEmail/controllerV2.js` β€”
  `moment().calendar(...)` β†’ `formatNotificationDate(...)`.
* `Modules/tasks/helpers/helper.js` and
  `Modules/tasks/helpers/mongo_helper.js` β€” `changeDateFormat`
  delegates to `formatDate` (which keeps the moment-style API its
  callers pass).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(BUG-043): wrap clickable <span>/<img> affordances in real <button>

Two affordances dispatched click handlers on non-semantic elements
(no role, no aria-label, no keyboard focus, no screen-reader
announcement):

* `frontend/src/components/atom/Modal/Modal.vue` β€” the modal close
  icon was a bare `<img @click="closeModal()">`. Wrap in
  `<button type="button" :aria-label="$t('Projects.close') || 'Close'">`
  with the icon inside as a presentational `<img alt="">`.
* `frontend/src/components/atom/Attachments/Attachments.vue` β€” the
  "Download All" affordance was a `<span @click="downloadAllImages()">`.
  Replace with `<button type="button" class="download-all-btn ...">`.

Both components' stylesheets gain a tiny rule to strip native button
chrome (so the visual result matches the old elements) but preserve
`:focus-visible` so keyboard users see the focused state.

Note: a couple of nearby affordances (Attachments' "See All" `<div>`
and the help-icon popover) have the same anti-pattern but are out of
scope here β€” the audit specifically flagged the `<span>` and `<img>`
cases above.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(BUG-044): give Modal role=dialog, aria-modal, and focus trap

The Modal atom rendered a bare `<div class="modal">` β€” no role,
no aria-modal, no aria-labelledby. Screen readers couldn't announce
the modal as a dialog and focus could leave the modal via Tab and
reach background controls.

Changes to `frontend/src/components/atom/Modal/Modal.vue`:

* Template:
  - Root `<div class="modal">` gains `role="dialog"`,
    `aria-modal="true"`, `:aria-labelledby="titleId"`, and
    `tabindex="-1"` (so focus can land on the dialog itself when no
    children are focusable).
  - The title `<span>` now carries the `:id="titleId"` that
    aria-labelledby points at.
  - `@keydown.tab="handleTabKeydown"` traps Tab; `@keydown.esc.stop`
    closes the modal on Escape.

* Script:
  - `modalRef` template ref + `titleId` computed.
  - `handleTabKeydown` cycles focus between the first and last
    focusable descendants (queries the standard focusable selector
    list, skipping aria-hidden and offscreen elements).
  - `activateFocusTrap` captures the previously-focused element and
    moves focus to the first focusable inside the modal (or the
    dialog itself if there are none). Called on mount-if-open and
    when `modelValue` flips to true.
  - `deactivateFocusTrap` restores focus to the captured element on
    close / unmount.

No new dependency: the trap is ~40 lines of inline logic; we don't
need `focus-trap` for one component.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(BUG-045): wire up the jest test suite

`npm test` was a placeholder (`echo "Error: no test specified" && exit 1`)
even though jest was already installed as a devDependency. This PR
wires it up as a working bootstrap suite that future fixes can grow
into.

Changes:

* `package.json` β€”
  - `test` now runs `jest`.
  - new `test:watch` (development) and `test:naming` (the pre-existing
    structural-audit test, kept off the default run because it flags
    legacy naming inconsistencies tracked separately).
* `jest.config.js` β€” points jest at `tests/`, ignores
  node_modules/frontend/installation/time-tracker-app/.claude and the
  naming-conventions audit, and runs in `node` environment.
* `tests/smoke.test.js` β€” minimal guarantee the harness is alive
  (always runs).
* `tests/utils/dateHelpers.test.js`, `tests/utils/safeServiceFile.test.js`,
  `tests/utils/imageGuard.test.js` β€” behaviour tests for the helpers
  introduced earlier in this audit (BUG-042, BUG-036, BUG-023). Each
  suite is wrapped in a graceful skip when its target module isn't on
  the current branch, so this PR lands cleanly today and the tests
  light up automatically once the corresponding helper PRs merge to
  staging.

`npm test` exits 0 β€” 3 passed (smoke) + 16 skipped (helpers pending
merge). After BUG-023/036/042 merge, all 19 should pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(BUG-046): let Mongoose manage history timestamps

`Modules/tasks/helpers/{helper,mongo_helper}.js`'s `HandleHistory`
wrote `createdAt` / `updatedAt` manually using `DateTime.utc().ts`,
which is a numeric millisecond value β€” not a BSON Date. Existing
documents ended up with inconsistent shapes (number vs Date) depending
on which code path created them.

Every other schema in `utils/mongo-handler/createSchema.js` is built
with `{ timestamps: true }` so Mongoose owns the timestamps. The
history schema alone was missing that option.

Changes:

* `utils/mongo-handler/createSchema.js` β€” add
  `{ strict: false, timestamps: true }` to `historySchema`.
* `Modules/tasks/helpers/mongo_helper.js` (HandleHistory) and
  `Modules/tasks/helpers/helper.js` (HandleHistory) β€” drop the manual
  `createdAt: utcDateTime.ts, updatedAt: utcDateTime.ts` lines.
  Mongoose populates both as BSON Dates on `.save()`.
* `utils/mongo-handler/schema.js` β€” keep `createdAt`/`updatedAt`
  declared on the history shape so callers that read them still work,
  but drop `required: true`. Mongoose sets them on `.save()`; making
  them required on updates would reject legacy docs that never had
  them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(BUG-047): ARIA semantics + keyboard nav for CustomDropDown

`frontend/src/components/molecules/DropDown/CustomDropDown.vue` is the
shared custom dropdown atom used across the app. Pre-fix:

* The trigger `<div @click="buttonClick()">` had no role,
  no aria-haspopup, no aria-expanded, no tabindex β€” keyboard users
  couldn't reach or open it.
* The floating panel had no role="listbox" β€” screen readers couldn't
  identify it as a menu.
* The mobile-view close affordance was a bare clickable `<img>`.

Changes:

* Trigger gains role="button", tabindex="0", :aria-haspopup="'listbox'",
  :aria-expanded (bound to open state), :aria-controls (panel id).
* Trigger handles keyboard:
  - Enter / Space β€” open/close (mirrors click).
  - Escape β€” close.
  - ArrowDown β€” open AND move focus to the first focusable child
    inside the options slot. (We can't enforce role="option" on
    user-provided slot content, but moving focus there gets keyboard
    users navigating immediately.)
* Floating panel gains role="listbox" and an @keydown.esc handler.
* Mobile close `<img>` wrapped in a real `<button type="button">`
  with aria-label="Close" and a style rule (`.dropdown-close-btn`)
  that strips native chrome but preserves :focus-visible.
* New helpers: `closeDropdown()` and `openAndFocusFirstOption()`.

Note: the sibling `DropDown.vue` and `MobileDropDown.vue` follow the
same anti-pattern but were not flagged by the audit; they'd benefit
from a similar sweep in a follow-up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Done - Test Case

Done - Test Case

* Add Gitigonre

Add Gitigonre

* Rename the folder Auth

Rename the folder Auth

* Rename Folder

Rename Folder

* Chnage the name

Chnage the name

* Folder Rename

Folder Rename

* remove dist

remove dist

* Create yml

Create yml

* Update main.yml

* Update main.yml

* Update main.yml

* Basic Setup

Basic Setup

* fix: repair broken advisory URL in SECURITY.md (#44)

The advisory link was split across two lines and wrapped in a code span,
rendering as malformed text rather than a clickable link. Joined the URL
and used proper markdown link syntax. Pairs with enabling Private
Vulnerability Reporting in repo settings so external reporters can
actually submit advisories.

Closes #44

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: refresh correct lastRequest field on connection reuse (#42)

updateConnectionRecord was writing to lastRequset (typo) while the
idle-cleanup loop in startInterval reads lastRequest. Active company
databases therefore kept the timestamp frozen at createdAt and were
eligible for termination after the 30-minute window despite continuous
use. Fixed the property name so the update path and the cleanup path
reference the same field.

Closes #42

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: remove duplicate CheckInstallStep mount and .env hot-reload (#41)

Two related sources of duplicate-handler accumulation in the bootstrap:

1. CheckInstallStep was mounted twice: once inside
   initializeControllers() and once unconditionally at the top level.
   The top-level mount has to stay because the install wizard must be
   reachable before MONGODB_URL is configured. Removed the inner mount
   so each startup registers it exactly once.

2. fs.watchFile on .env called initializeControllers() on every save,
   which re-ran .init(app) for ~60 route modules onto the same Express
   app instance (Express has no clean route un-registration) and spun
   another setInterval inside startInterval(). Removed the watcher;
   nodemon already restarts on file changes in dev, and production
   env changes require a process restart anyway.

After this change initializeControllers() is only invoked once, from
the MONGODB_URL startup gate.

Closes #41

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: preserve dataType and options across JWT refresh retry (#38)

apiRequest and apiRequestWithoutCompnay reconstruct the call after a
token refresh using only (type, endPoint, data), dropping dataType
and options. Any retry of a multipart upload then runs through the
JSON axios instance instead of the form-data instance, and any
caller-supplied abort signal or per-request option is lost. Forwarded
dataType and options into both retry calls so the replay matches the
original.

Closes #38

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: clean up close_click IPC listener after each notification (#39)

sendNotification() registered a new ipcMain.on('close_click', ...)
handler on every screenshot capture and never removed it, so the
process accumulated one global IPC listener per screenshot and a
single close click eventually fired N stale handlers, each trying to
close an already-destroyed BrowserWindow reference.

Switched the registration to ipcMain.once so the happy path
(user clicks close) auto-removes the listener. For the auto-timeout
path (window closes after 10s without a click) the handler stays
registered and would later steal a future notification's close
event, so also remove it explicitly when the window emits 'closed'.
The timeout id is now tracked and cleared on the same hook so the
timer can't fire against a destroyed window. The window reference
is captured in a local so each listener targets its own window even
if a subsequent sendNotification() reassigns the module-level
screenshotNotificationWindow before this listener fires.

Closes #39

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: allow editing the "Created by" user on a task

The task sidebar Details panel previously showed Created by (the
Task_Leader field) as a read-only user β€” set once at creation and
never changeable. Add an inline picker mirroring the existing
Assignee field so the creator can be reassigned.

Changes:
- Backend: new updateTaskLeader action on task_class_Mongo. Validates
  the new Task_Leader, writes via $set, emits the same
  socketEmitter.emit('update', { module: 'task', updatedFields }) used
  by updateAssignee, and logs a TaskLeader_Changed history entry via
  HandleHistory. Includes the isUpdateTask:false side-effects-only
  branch for parity with updateStatus / updatePriority.
- Frontend store (TaskOperations): new updateTaskLeader action that
  optimistically commits the new Task_Leader into the projectData
  Vuex store, then PATCHes /api/v2/tasks with action 'updateTaskLeader'.
- TaskDetailRightSide.vue: replaced the read-only Created-by block
  with an Assignee picker (single-select) for users holding the
  task.task_assignee + task.task_list permissions, preserving the
  original read-only display as the fallback for users without those
  permissions. Wired @selected to a new updateTaskLeader() handler
  with the same toast / error pattern as updateAssignee.
- i18n: added Toast.Created_by_updated_successfully and
  Toast.Created_by_not_updated to the English locale. The other 10
  locales fall back to English via vue-i18n until translators
  backfill β€” flagged as a follow-up.

Permission policy: reuses task.task_assignee. Anyone allowed to
change the Assignee can also change Created by. No new permission
key, no role-permission migration. Backend has no per-field gate,
consistent with the existing Assignee / Status / Priority update
paths which all trust the frontend permission gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: split header notifications into Unread and Archive views

Previously the notification bell fetched every notification the user
had ever received (read or unread) in a single combined list, making
it hard to focus on actionable items. This change splits the dropdown
into two views β€” Unread (default on every open) and Archive β€” gated
by a single toggle button in the dropdown head.

Changes:
- Backend: GET /api/v1/app-notification/notification accepts a new
  optional `filter` query param ('unread' | 'archived'). 'unread' adds
  `notSeen: { $in: [userId] }` to the aggregation match; 'archived'
  adds `notSeen: { $nin: [userId] }`. Default is 'unread' to keep the
  bell focused on actionable items. Refactored the match clauses into
  a `baseMatch` array so the filter is appended cleanly; query shape
  and pagination are otherwise unchanged.
- Header.vue: added a `notificationFilter` ref ('unread' by default),
  a "View Archive" / "View Unread" toggle in the dropdown head, a
  `switchNotificationFilter()` handler that resets paging state and
  refetches, and an `openNotificationsDropdown()` helper that the
  bell click handlers now use so each fresh dropdown open lands in
  Unread. `markRead()` and `markAllRead()` clear the read items from
  the local list while the Unread filter is active so the dropdown
  reflects the filter without an extra refetch. The "Mark all as
  read" button visibility now keys off `notifications.length` (the
  list is already filtered to unread on this view) rather than the
  server-side `totalNotification` counter, which can lag and falsely
  hide the button.
- i18n: added Header.View_Archive / Header.View_Unread to the English
  locale; other locales fall back to English via vue-i18n until
  translators backfill (flagged as a follow-up).

Out of scope (deferred per user instruction): goals 3 and 4 from the
original spec β€” creator-prefs fire policy verification and email
preference parity audit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: honour project-level Ignore for task creator and company owner

Closes goals 3 & 4 of the notification refactor. Two parallel bypasses
in handleNotification.HandleBothNotification let task creators and
company owners through the project-level watcher filter (the Ignore /
All Activity / Participating setting in the List of Watcher panel):

- Task branch (taskId path): Task_Leader was Set-union'd into the
  recipient list AFTER `projectData.watchers` had already filtered out
  any user set to "ignore". So a creator who had set the project to
  Ignore still ended up in `assigneeUsers`, and the downstream
  per-event preference check (NOTIFICATIONS_SETTINGS β€” a separate
  preference layer that defaults email=true) emitted both an in-app
  and an email notification. Now Task_Leader is included only if their
  project-watcher setting is not "ignore".

- Project branch (type === 'project'): same pattern β€” companyOwnerId
  was union'd in unconditionally, bypassing the watcher filter.
  Honours "ignore" too.

This explains the goal-4 symptom of "I'm getting emails for events I
disabled": the disablement was set at the project-watcher layer, but
the bypass routed the recipient straight to the per-event layer where
email was still on.

Other recipient paths are untouched. Default behaviour for users who
have not chosen "ignore" is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: per-company screenshot retention policy + nightly cleanup cron

Adds an opt-in retention policy on the time-tracker screenshot pipeline.
Visible on /settings/setting to the company owner (roleType === 1)
only. When enabled, a nightly cron permanently deletes trackshots
older than the configured window (3 / 6 / 12 / 24 months) from both
the per-tenant TimeSheet.trackShots subdoc array and from Wasabi
(main object + every thumbnail variant).

Backend
=======
- utils/mongo-handler/schema.js: new `screenshotRetention` Map field
  on the global companies schema. Stores `enabled`, `maxAgeMonths`,
  `enabledAt`, `enabledBy`, `lastRunAt`, `lastRunStats`,
  `firstRunCompletedAt`, `runningSince`. Backward-compatible β€” legacy
  companies without the field read as `enabled: false`.

- Modules/ScreenshotRetention/: new module.
    helper.js   β€” policy read/write, preview counter, per-company
                  cleanup workflow, and the cron entry point.
                  Production-ready guarantees:
                    * Wasabi delete BEFORE db $pull so transient
                      Wasabi failures leave the db record intact and
                      the next nightly run retries (no permanent
                      orphans).
                    * Per-trackshot main + 4 thumbnail keys deleted
                      (sizes hard-coded from thumbnail.json).
                    * Filters by trackshot.screenShotTime (epoch ms),
                      not parent TimeSheet timestamp.
                    * Advisory `runningSince` lock prevents
                      double-runs; stale locks (>4h) are reclaimed.
                    * First-run safety cap (50k deletions) for the
                      initial cleanup on legacy data; lifted once
                      `firstRunCompletedAt` is stamped.
                    * Bounded company concurrency (5 in parallel)
                      via Promise.allSettled.
    controller.js β€” three endpoints with owner role check:
                    GET  /api/v1/screenshot-retention
                    GET  /api/v1/screenshot-retention/preview
                    PUT  /api/v1/screenshot-retention
                    Owner check looks up the per-tenant
                    `company_users` doc for the caller and confirms
                    `roleType === 1`. Returns 403 on mismatch.
    routes.js     β€” endpoint registration.
    init.js       β€” module bootstrap (matches existing convention).

- index.js: register the new module beside the rest of
  `initializeControllers()`.

- cron.js: removed the broken `cleanUpTrackshot()` call (was
  referencing an unimported binding and never executed). Added a new
  schedule at 00:30 UTC that invokes
  `screenshotRetention.runRetentionForAllCompanies()`. Off-peak vs the
  other midnight jobs so a heavy cleanup doesn't compound with the
  bucket-size + AI-reset jobs on the same minute.

Frontend
========
- frontend/src/components/molecules/Setting/SettingScreenshotRetention.vue:
  new card mounted on /settings/setting. Renders only for
  `companyUser.roleType === 1`. Toggle + retention-window dropdown +
  last-run telemetry. Enabling fires a SweetAlert confirmation that
  shows the preview-count from the new GET preview endpoint so the
  owner knows exactly what will be deleted on the next nightly run.

- frontend/src/views/Settings/Setting/Setting.vue: mount the new
  component in the existing right-hand column. Self-hides for
  non-owners.

- frontend/src/locales/en.js: new ScreenshotRetention.* keys (heading,
  toggle/window labels, confirmation copy, last-run telemetry). Other
  locales fall back to English via vue-i18n.

Out of scope (deferred)
=======================
- Translation backfill for non-English locales.
- A `RetentionAuditLog` collection for per-run history beyond
  `lastRunStats`. The cron emits structured logs in the meantime.
- A "dry run" mode that lets the owner see what would be deleted
  without enabling the policy. The preview endpoint already gives the
  count; a per-record preview would need its own UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: comment out unused companyId injection in SettingScreenshotRetention component

* feat(setup): one-command developer setup via `npm run setup`

Adds an additive setup orchestrator that installs deps, builds the wizard,
bootstraps .env, starts backend + frontend, completes the installation wizard
headlessly, creates a default admin account, and opens the login page β€” all
from a single command. Non-technical contributors can go from `git clone` to
a working login screen with no manual steps.

New
- scripts/dev.js β€” orchestrator with HTTP-based wizard auto-completion,
  MongoDB probe with retry, defensive .env patching, credentials banner.
- nodemon.json β€” explicit watch list (server-side dirs / .js only) so wizard
  writes to installationSteps.json no longer restart the backend mid-request
  (root cause of the "wizard reloads on MongoDB step" bug).
- package.json β€” `setup`, `dev`, `setup:reset` scripts + nodemon devDep.

Wizard improvements (back-compat preserving)
- Modules/CheckInstallStep/controller.js: `isDoItLater` support added for
  Firebase (step 4) and SMTP (step 6), mirroring the existing AI (step 5)
  skip pattern. Both steps remain mandatory unless the caller opts in.
- Defensive APIURL fallback at module-load (was crashing the backend on
  fresh clones if .env hadn't loaded yet β€” TypeError on .substring of
  undefined).

.env.example
- SERVICE_FILE corrected from "../firebase-adminsdk.json" to
  "./firebase-adminsdk.json" (the prior default tripped the BUG-036
  path-traversal guard and blocked the Firebase wizard step).
- Quick-start comment block at the top.

Nothing existing changes
- `npm start`, `npm run nodemon`, `npm run basic-install`, the interactive
  wizard, and the documented manual setup paths are all untouched.
- New scripts never run automatically; users must invoke them explicitly.
- Fallback chain at every failure point (MongoDB unreachable / auto-setup
  error / --manual flag) opens the interactive wizard UI so the user is
  never left in an unrecoverable state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(screenshot-retention): security + correctness pass on PR #157

Independent review of the original commit surfaced three blockers and
several correctness issues. This is a follow-up commit on the same
branch that addresses all of them.

BLOCKERS
========

1. **Routes had no JWT middleware.** Config/setMiddleware.js gates auth
   via path allowlists. The new `/api/v1/screenshot-retention` and
   `/api/v1/screenshot-retention/preview` paths were NOT listed in
   either `verifyJWTTokenWithCRoute` or `verifyJWTToken`, so the
   endpoints shipped completely unauthenticated β€” anyone on the
   internet could toggle retention for any company. Added both paths
   to `verifyJWTTokenWithCRoute` so the JWT middleware fires and
   populates `req.uid` / `req.aud`.

2. **Thumbnail keys were generated in the wrong dimension order.** The
   upload-time call at Modules/storage/wasabi/controller.js:298 passes
   `(thu.height, thu.width)` into a function whose params are
   `(width, height)`, so the stored filename is effectively
   `<base>-<height>x<width>.<ext>`. The cleanup helper was building
   `<base>-<width>x<height>.<ext>` and missing every thumbnail variant
   on every retention run β€” leaving four orphan Wasabi objects per
   trackshot, permanently. derivThumbnailKeys now mirrors the
   upload-time swap with a comment explaining the pre-existing bug
   in the upload path (out of scope to fix here).

3. **Cursor-based pagination replaces skip-based scan.** The previous
   loop advanced `pagedSkip += docs.length` after each batch, but
   the `$pull` inside the loop removes docs from the match set β€”
   meaning the next page's skip lands past where it should. Most of
   the DB was being missed on legacy-data tenants. Replaced with
   `_id > lastSeenId` cursor that's immune to in-flight match-set
   shrinkage.

SECURITY
========

4. **Controller now reads userId from `req.uid`** (set by
   Config/jwt.js#checkToken from the verified token's `uid` claim),
   not from `req.body.userId`. The old code let a non-owner pass the
   owner's userId in the body and pass the role check. Status codes
   updated: missing userId β†’ 401 (authentication required) rather
   than 400.

HIGH
====

5. **Legacy string-typed `screenShotTime` is now handled.** Multipart
   form uploads coerce numbers to strings at write time, so the
   `screenShotTime` field on legacy trackshots is stored as a string.
   The previous `$lt: cutoffMs` (number) didn't match strings.
   - `countOldTrackshots` now uses `$convert` with
     `to: 'long', onError: null` inside the $filter so the comparison
     works regardless of the field's actual storage type.
   - `runRetentionForCompany`'s loose DB query + strict in-memory
     filter coerces via `Number(t.screenShotTime)` and skips
     non-finite values.

6. **`firstRunCompletedAt` is now stamped only when a real cleanup
   completed.** Previous condition `deletedCount < cap` stamped the
   marker on a zero-deletion run (e.g. a new company with no eligible
   data), which lifted the first-run safety cap before any real bulk
   data accumulated. New condition: scan exhausted (no cap hit, no
   error) AND deletedCount > 0.

7. **Empty-image trackshots no longer inflate `deletedCount`.** The
   scan loop now pre-filters them out (`oldShots` requires `t.image`),
   and `deleteTrackshotObjects` returns `skipped: true` rather than
   `mainDeleted: true` if it's ever called with a missing key.
   Stats now track `skippedCount` separately.

MEDIUM
======

8. **Removed the no-op `updateCompanyPolicy(companyId, {}, {})`
   call** that hit the empty-patch early-return at line 176 without
   writing anything. Lock acquisition is a single `stampMasterField`
   call, which is at least document-atomic. Cluster-aware leader
   election is still out of scope (documented).

9. **`lastRunStats` now records partial-run errors and cap-hit state.**
   New stats fields: `skippedCount`, `hitCap`, `error`. Ops can tell
   from the master doc whether the run completed cleanly, hit the
   cap, or threw mid-loop.

10. **Frontend now uses `watch(isOwner, …, {immediate: true})`** instead
    of one-shot `onMounted`. The Vuex `companyUserDetail` store
    sometimes hydrates after the component mounts on soft route
    changes; the one-shot mount would never call `loadPolicy()` in
    that case and the card stayed on hard-coded defaults forever.

LOW
===

11. **`extractKey` regex tightened** from `[a-f0-9]{20,}` to
    `[a-f0-9]{24}` (exact ObjectId length) and now decodes URL
    path segments so percent-encoded keys map back to their
    canonical storage form.

12. **`getS3Client` doc + body cleaned up.** `s3Client` is not exported
    from `wasabi/controller.js`, so the old "fall back if not
    available" branch was always taken. Removed the misleading
    primary path; we just build the client from `awsRef` and
    memoise it.

Test plan addendum
==================
- Hit `GET /api/v1/screenshot-retention` without a JWT β†’ 401.
- Hit `PUT /api/v1/screenshot-retention` with a forged body
  `userId` of the owner from a non-owner JWT β†’ 403 (controller
  uses `req.uid`, not body).
- On a tenant with legacy string-typed `screenShotTime`:
  - GET /preview returns a non-zero count for the configured window.
  - A nightly run deletes those records + their (heightΓ—width-named)
    thumbnail variants.
- Confirm thumbnail keys deleted in Wasabi by listing the bucket
  after a run β€” there should be no orphans for the deleted main
  keys.
- Confirm pagination handles a tenant whose every TimeSheet has at
  least one expired trackshot (all docs drop from match set on
  first $pull): the cursor variant should still terminate cleanly
  and process every doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(security): P0 + partial P1 hardening, CI pipeline, multer v2

Closes the critical security and dependency gaps surfaced in the
codebase audit. All 20 unit tests pass; no behavior change for normal
request paths.

Backend security
- Sanitize regex search inputs across 7 controllers (AI,
  AdvancedGlobalFilter, Comments, MediaFiles, Project filter,
  notification-count, trackerDownload) via new utils/escapeRegex.js.
  Blocks NoSQL regex injection / ReDoS / cross-tenant name enumeration.
- Add helmet + global express-rate-limit in index.js. CSP disabled to
  preserve the Vue inline-script setup; secure / sameSite already
  env-gated correctly. trust-proxy set to 'loopback' to silence the
  ERR_ERL_UNEXPECTED_X_FORWARDED_FOR warning behind a same-host
  reverse proxy.
- Harden all 4 multer call sites via new utils/uploadConfig.js
  (DEFAULT_LIMITS, safeFileFilter blocking executable extensions,
  safeRelativePath for traversal protection). bucket.helper.js
  storage destination now validates path before writing.
- New requireCompanyAud middleware (Config/jwt.js) enforces the JWT
  `aud` claim against any companyId in body / params / query /
  header on the ~50 verifyJWTTokenV2-only routes. Non-ObjectId
  values (e.g. USER_PROFILES global bucket) pass through.

Dependencies
- Remove aws-sdk v2 from package.json (EOL Sept 2025); only @aws-sdk
  v3 was actually imported.
- Bump multer 1.4.5-lts.1 -> 2.1.1 to clear known 1.x CVEs.

Schema strictness (P1-SEC-11)
- utils/mongo-handler/createSchema.js: flipped 7 core schemas to
  `strict: true` (tasks, comments, timesheet, history, adminDetail,
  subscriptionPlan, globalCustomFields). Kept `strict: false` on 7
  intentionally-dynamic schemas (notification counters, Chargebee
  webhook mirrors, custom-field definitions, plan-feature maps) with
  inline comments explaining why.

CI / DevOps
- .github/workflows/main.yml: added a `validate` job (npm test, npm
  audit advisory, frontend install + build) that the deploy job now
  `needs:`. Previously deploys ran with zero validation.

Diagnostics
- modules/storage/wasabi/controller.js: replaced 6 generic
  `Error while upload file: ${error}` rejects with a
  formatS3UploadError helper that logs the AWS error Code, HTTP
  status, bucket, key, and requestId.

Documented for follow-up
- Auth/controller.js: TODO comments at both res.cookie sites
  explaining why httpOnly stays false until the frontend stops
  reading the cookie via js-cookie (P1-SEC-09 deferred).

Pre-existing fixes pulled in
- tests/utils/imageGuard.test.js: corrected env var names so the
  suite goes from 19/20 to 20/20 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(safeServiceFile): reject Windows absolute paths on any host

The CI pipeline added in the same PR is the first thing to run
tests on Ubuntu. `path.isAbsolute('C:\Windows\...')` returns
false on Linux, so the safeServiceFile absolute-path guard fell
through and the request hit the `.json` extension check instead.
The test in `tests/utils/safeServiceFile.test.js:46` expects the
"relative" error in both shapes and was passing on Windows hosts
by accident.

Use `path.win32.isAbsolute(...)` alongside the platform-bound
`path.isAbsolute(...)` so both POSIX (`/etc/hosts`) and Windows
(`C:\Windows\...`) absolute paths are rejected regardless of
which OS the process runs on. This is also a defensive improvement
to BUG-036: a Linux deployment can no longer be coaxed into
re-resolving a Windows-shaped path inside the project root.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Update main.yml

* Update main.yml

* feat(ai-project-generator): one-shot AI project bootstrap with PDF brief + SSE progress

Adds a parallel "Create with AI" path to the existing manual project wizard.
The user describes their project in natural language (optionally attaches a
PDF/DOCX/MD/TXT brief and chooses public/private workspace + a target task
count), the LLM returns a plan (project metadata + folders β†’ sprints β†’
tasks with rich descriptions), the user reviews/edits names, and on
"Create everything" the orchestrator commits the whole hierarchy via the
same write paths the manual flow uses β€” with SSE progress events and
rollback on partial failure.

== Backend ==
- New module Modules/AIProjectGenerator with:
  * llmProvider/ β€” env-selected adapter (openai | anthropic). Reads
    AI_API_KEY/AI_MODEL (existing) for OpenAI and ANTHROPIC_API_KEY/
    ANTHROPIC_MODEL (new) for Anthropic, picked via LLM_PROVIDER.
  * briefExtractor.js β€” multer v2 disk-buffer + pdf-parse + mammoth, 10MB
    cap, mime allow-list, control-char strip, 100k char truncation.
  * schemaValidator.js β€” zod PlanSchema with strict task/status/folder
    rules; sanitizes member ids against the active company roster.
  * promptTemplates.js β€” system + user + repair prompts. Forces 4-8
    tasks per sprint, domain-specific status names, full lifecycle coverage.
  * orchestrator.js β€” sequential project β†’ folders β†’ sprints β†’ bulk tasks.
    Reserves a taskKey range atomically, emits SSE per step, soft-rollbacks
    in reverse on any failure (tasks β†’ sprints β†’ folders β†’ project +
    projectCount decrement).
  * sseEmitter.js β€” heartbeat-equipped SSE channel keyed by random jobId.
  * Endpoints: /api/v1/ai/project/{upload-brief,plan,clarify,execute}
    + unauthenticated /api/v1/ai-progress/:jobId (jobId is a bearer
    capability β€” same pattern as /api/v1/generatePrompt/events).
- Config/setMiddleware.js: protected new auth paths via
  verifyJWTTokenWithCRoute; SSE endpoint deliberately omitted.
- index.js: register the new module after Modules/AI.
- .env.example: LLM_PROVIDER, ANTHROPIC_API_KEY, ANTHROPIC_MODEL,
  LLM_MAX_TOKENS_PLAN, LLM_MAX_TOKENS_CLARIFY.
- package.json: +@anthropic-ai/sdk, +pdf-parse, +mammoth, +zod.

== Frontend ==
- New AiProjectCreator.vue (organism) β€” 3-step sidebar:
  1. Describe: textarea (20-char minimum), public/private workspace toggle
     (mirrors manual ProjectWorkspace step), target-task-count slider,
     PDF/DOCX/TXT/MD upload, inline clarification Q&A.
  2. Review plan: project icon/code/description preview + inline-editable
     names at every level (project / folder / sprint / task), expandable
     task descriptions.
  3. Execute: live SSE progress UI (project β†’ folders β†’ sprints β†’ tasks)
     with rollback-aware error state and "Open project" CTA on complete.
  Close affordance is a close icon (was a Cancel button) β€” disabled
  while uploading/loading; the sidebar can't be dismissed during the
  execute phase to prevent orphaned in-flight jobs.
- New aiProjectGenerator.js composable wraps the four endpoints +
  EventSource subscription.
- Projects.vue / ProjectListComponent.vue / ProjectListing.vue add the
  "✨ Create with AI" button beside "+ New Project", gated on
  currentCompany.planFeature.aiPermission.
- env.js: 5 new endpoint constants.

== Highlights from QA pass ==
- projectIcon is persisted in the canonical {type:'color', data:'#hex'}
  shape Item.vue expects, so the AI-bootstrapped project's color/initial
  pill renders in the sidebar (was an empty box).
- Multer rejection (LIMIT_FILE_SIZE, unsupported mime, etc.) is caught
  before Express's default handler so users see "File is too large.
  Maximum allowed size is 10 MB." instead of "Request failed with status
  code 500".
- The user's explicit public/private choice in step 1 is forced onto the
  plan server-side at /plan, /clarify and /execute β€” it always wins over
  whatever the LLM emitted.
- Plan-feature quota (projectCount.*) is incremented like the manual
  flow but the AI path intentionally skips checkProjectPlan's hard cap
  so this feature remains usable on plans that limit project counts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci(main.yml): map VUE_APP_* secrets into the frontend build step

The CI frontend build was running with no .env present (gitignored, not
materialised on the runner) so every `process.env.VUE_APP_*` reference
was inlined as `undefined` in the bundle. Map the Firebase keys, storage
config, support-routing ids, and OAuth feature flags as repository
secrets in the Build frontend step so the validation build matches what
production actually ships.

Also bumps Node to 22, switches to npm ci, adds concurrency cancellation
for staging deploys, and tightens the workflow trigger list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(env): update AI model to gpt-4o-mini in .env.example

* refactor(projects): split Projects.vue and Task.vue mega-components (#167)

Projects.vue: 2,737 -> 1,365 lines (50% reduction). Task.vue: 1,015 -> 540 lines (47% reduction).

- 9 composables extracted (useProjectCalendar, useProjectAvatar, useEmbedViews,
  useProjectLifecycle, useProjectAssignee, useProjectTour, useProjectSearch,
  useProjectRules, useProjectNameEdit)
- 5 sub-components extracted (ProjectActionsBar, ProjectFiltersToolbar,
  ProjectSidebars, ProjectBottomModals, ProjectEmptyState)
- 7 view components (ListView, Comments, ActivityLog, WorkloadView, BoardView,
  ProjectDetail, TableView, EmbedViewItem) converted to defineAsyncComponent
  so each tab pulls its own chunk on demand
- 2 task composables (useTaskMutations, useTaskActions) and 1 sub-component
  (TaskQuickMenu) extracted; duplicate mobile/desktop quick-menu markup deduped

Bundle (npm run build): main project chunk 2,621,285 -> 2,047,548 bytes
(-22%, -560 KB). New lazy chunks: project-list-view 381 KB, project-detail
117 KB, project-kanban 42 KB, project-table-view 28 KB, project-workload
17 KB, embed-view 3 KB, project-activity-log 0.5 KB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(backend): split task/logtime/milestone/auth mega-files (#164)

Splits four oversized source files into focused sub-modules so no file in
the affected modules exceeds ~640 lines (issue target: ~800). Public API
is preserved: each original module path keeps re-exporting every symbol
it used to, so routes.js and downstream consumers are untouched.

- Modules/Tasks/helpers/task_class_Mongo.js (3,129 -> 27 lines)
  Mixin pattern under taskMongo/: create, updateBasic, updateAssignment,
  updateMeta, structural, mergeDuplicate, internals. Methods merge into
  Task.prototype via Object.assign so internal `this.X(...)` calls and
  the `taskMongo` singleton work unchanged.

- Modules/LogTime/controllerV2.js (1,739 -> 12 lines)
  Re-export index over controllerV2/: helpers (3 utilities used by
  Tasks/EstimatedTime), manualLogtime, tracker, capture, timelog.
  Internal `exports.updateRemainingTime(...)` etc. rewritten to direct
  calls now that the utilities live in a sibling helpers.js.

- Modules/Milestone/controller.js (1,193 -> 10 lines)
  Re-export index over controller/: helpers (6 notification/history
  utilities), crud, status, query.

- Modules/Auth/controller.js (1,061 -> 11 lines)
  Re-export index over the existing controller/ folder, adding
  authHelpers (5 internal helpers, incl. the externally-imported
  addAndRemoveUserInMongodbNotificationCount), register, loginSession,
  password.

Verification: jest 20/20 passing; smoke-checked all 80 original exports
remain reachable on their original module paths; loaded routes.js and
every downstream consumer (Company/controller, CheckInstallStep/createCompany,
EstimatedTime/controller, Auth/controller/createUser) without errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(auth): relocate stranded verify*Auth helpers + repair inline require paths (#164)

Follow-up to 5c24855. The original splitter only tracked top-level
`exports.NAME = ...` declarations, so five non-exported helpers between
`registerAuth` and `verifyAuth` in the source file (verifyGithubAccessToken,
verifyGithubAuth, verifyGoogleIdToken, verifyGoogleAuth, verifyLocalAuth)
got swallowed into `registerAuth`'s line range and ended up in
`controller/register.js`, while their only caller β€” `verifyAuth` β€” landed
in `controller/authHelpers.js`. Every login attempt threw
`ReferenceError: verifyLocalAuth is not defined` at runtime.

The previous smoke test missed it because it only checked
`typeof exports.verifyAuth === 'function'`, which is true even when calling
the function blows up.

Changes:
- Move the 5 verify*Auth helpers into authHelpers.js, placed before
  verifyAuth so their definitions are in scope at call time.
- Patch inline `require()` paths that the preamble-rewriter didn't touch
  (they live inside function bodies, not at file top):
  * authHelpers.js: `../Template/passwordExpiredMail` -> `../../Template/passwordExpiredMail`
  * authHelpers.js: `../Template/forgotPassword`     -> `../../Template/forgotPassword`
  * Drop the inline `const logger = require("../../Config/loggerConfig")`
    inside verifyGoogleAuth β€” it was already redundant in the original
    (logger is imported at the top) and now also pointed to a non-existent
    depth.
- register.js trimmed to 31 lines (preamble + registerAuth only).

Verification:
- New smoke test actually invokes `verifyAuth` with each authProvider
  ('local', 'github', 'google') and asserts the early-return validation
  message β€” proving every verify* helper symbol resolves at runtime.
- Verified the two inline template require specifiers resolve to real
  files via `require.resolve`.
- Grep-confirmed no stale `../Template/` or `../../Config/` paths
  survive in any Auth sub-file.
- `npm test` 20/20 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai-project): default ProjectType to "Fix" to match manual flow

The two valid project types in AlianHub are "Fix" and "Hourly" (see
frontend/src/components/atom/ProjectType/ProjectType.vue). The manual
project creation flow hardcodes "Fix" on submit
(CreateProjectSidebar.vue, TemplateAllDetail.vue), and downstream
features like milestones (ProjectDetail.vue) only branch on those two
values.

The AI project generator was instead:
- Defaulting to "General" in the orchestrator and Zod schema
- Telling the LLM to invent ProjectType values like "Software",
  "Marketing", "Operations" β€” none recognised by the rest of the app

Changes:
- orchestrator.js: always assign ProjectType: "Fix" (drop LLM-supplied
  value).
- schemaValidator.js: default changed from "General" to "Fix" (defence
  in depth for any other consumers of the schema).
- promptTemplates.js: removed the ProjectType field from the LLM
  schema description so the model doesn't waste tokens generating an
  ignored value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* perf(mongo): bound the per-tenant Mongoose connection pool (#162)

Previously the per-tenant connection registry grew unbounded β€” every active
company kept a Mongoose connection forever (helper.js had a 30min idle sweep,
but no cap and the threshold was hardcoded). Under heavy multi-tenant load
this leaked sockets and RAM.

- Add LRU eviction in handleConnection: before opening a new connection,
  evict the least-recently-used entry that's outside a 5s grace window
  (the raw Connection is returned to callers, so we must not close one
  that may still be servicing a query).
- Make the idle sweep env-configurable: TENANT_CONNECTION_IDLE_MS and
  TENANT_CONNECTION_SWEEP_MS, defaults preserve the prior 30min/5min
  behavior. Iterate in reverse during sweep to avoid splice-skip.
- Centralize cleanup in closeAndRemove helper (also drops two debug
  console.logs that printed the full connection list every sweep).
- Document MAX_TENANT_CONNECTIONS (default 100), TENANT_CONNECTION_IDLE_MS,
  and TENANT_CONNECTION_SWEEP_MS in .env.example. Fresh setups pick these
  up automatically via scripts/dev.js's full-file copy; existing .envs
  rely on code defaults (no patching of patchMissingEnvKeys β€” these are
  optional tuning knobs, not boot-required).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(logging): replace console.log with Winston logger across modules (#165) (#177)

Replace all 56 console.log occurrences across 15 files in Modules/ with
the existing Winston logger (Config/loggerConfig.js), applying correct
log levels: error for catch blocks, warn for rejected batch items, and
info for progress/completion messages.

Add logger import to files that lacked it: Tasks/routes.js,
Project/controller/getSprintFolder.js, CheckInstallStep/controller.js,
and CheckInstallStep/initalizations.js.

The console.log inside the Firebase service-worker template string in
CheckInstallStep/controller.js is intentionally preserved β€” it is
browser-side code written to a .js file, not server-side Node.js.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(tasks): multi-select bulk actions across List/Kanban/Table views

Adds an end-to-end ClickUp-style bulk-action experience to the project
task views with full parity to single-task semantics.

Frontend
- New Vuex module `TaskSelection` + `useTaskSelection` composable;
  selection auto-clears on view/project switch.
- Hover/selected checkboxes added to ItemList rows, sprint headers,
  Kanban cards/columns, and Table rows/group headers β€” all gated by
  the same `checkPermission` keys the single-task path uses.
- Kanban drag is disabled when 2+ tasks are selected so multi-move
  goes through the bar (no ambiguous single-vs-multi drag).
- New `BulkActionBar` (with `BulkMenu` sub-component) mounted in
  Projects.vue. Single-open menu state with a capture-phase document
  listener that ignores teleported popups (.dp__menu / sidebar /
  drop-down-menu) so the calendar and assignee pickers work cleanly.
- Internal search in Status / Priority / Assignees / Tags menus.
- Assignee + tag rows: click to add, X icon to remove, with tri-state
  (`none` / `some` / `all`) computed against the current selection.
- Due date uses the existing `DueDateCompo` calendar, center-aligned
  in a custom wrapper.
- Optimistic store updates for delete/archive (deletedStatusKey +
  sprint count adjustment) and for status/priority/assignee/tags/
  due-date so the UI reflects changes immediately, matching the
  single-task pattern.
- Skipped/error toasts surface backend permission/scope failures
  with human-readable reasons.

Backend
- New `Modules/Tasks/helpers/taskMongo/bulk.js` mixin with
  bulkUpdateStatus, bulkUpdatePriority, bulkUpdateAssignee,
  bulkUpdateDueDate, bulkUpdateStartDate, bulkUpdateTags,
  bulkArchive, bulkRestore, bulkDelete, bulkMove, bulkDuplicate.
- New POST /api/v2/tasks/bulk route with dynamic action dispatch,
  mirroring the existing PATCH /api/v2/tasks pattern. companyId is
  taken from the verified header (never the body) to prevent spoof.
- Each bulk method does the DB write directly via updateMany for
  reliability, then calls HandleHistory + HandleBothNotification
  per task so activity logs and notifications match what N single
  calls would produce.
- Per-task `socketEmitter.emit('update', ...)` is fired with a
  merged post-update task doc so taskSocket.js fans the change
  out to all clients viewing that project+sprint room in real time
  with the correct new values (not stale ones).
- bulkArchive/bulkDelete/bulkRestore go through the existing
  `updateArchiveDelete` helper to preserve subtask cascades,
  parent count updates, sprint reconciliation, and comment count
  cleanup.

Permissions (gated at three layers)
- Selection visibility: hover checkbox only renders if the user
  has `task.task_status` on that project.
- Action button gating: each bar action ties to the existing
  per-action permission key (task.task_delete, task.task_priority,
  task.task_tag, etc.); buttons disable with a tooltip.
- Backend re-check: companyId scoping via loadScopedTasks drops
  cross-tenant ids into `skipped[]` before any write.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(aipg): collapse plan flow to one-shot, drop clarification + task-count slider

Removes the multi-turn clarification round-trip that was causing
"Conversation not found or expired" errors on staging-app β€” the proxy
intermittently 504s on /api/v1/ai/project/clarify and by the time the
user retries, the cached conversation has expired so the request 404s.

What changed:
- /api/v1/ai/project/clarify is replaced with a 410 Gone stub (kept
  for one deploy cycle so any cached frontend that still POSTs there
  gets a clear "retry /plan" response instead of a 404).
- /api/v1/ai/project/plan is now a single round-trip. The conversation
  cache (myCache "convo:" keys), conversationId param, and clarifyRound
  bookkeeping are gone.
- Frontend "Quick questions to sharpen the plan" card removed.
- Frontend "Target task count" slider card removed. The model now picks
  the task count entirely from the description + ruleset (4-8 tasks per
  sprint, hard ceiling of 100 plan-wide).
- The error UI on step 1 now shows a retry hint and the primary button
  re-labels itself to "Try again" β€” clicking it just re-runs the same
  /plan call. No state to lose between attempts.

System prompt change:
- "Clarification rule" now says: never ask questions, always set
  needsClarification=false, fill in reasonable defaults for any unclear
  bits and note assumptions inside task descriptions.
- "Target task count floor" language stripped; let the model pick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(table): adjust table layout and spacing for improved readability

* Fix comment duplicates and preserve drafts

* feat(ai-project-generator): implement async project plan generation with SSE support

* perf(socket): phase 1 quick-wins for real-time scalability

Five low-risk, backend-only optimizations to the socket event pipeline
and MongoDB connection layer. Wire protocol (event names, payloads) is
unchanged β€” no frontend changes required.

Fix #2 β€” Namespaced socket events
  Wrap the internal EventEmitter so payloads tagged with `module` also
  publish a `<module>:<event>` event. Migrate the 5 socket controllers
  and the notification middleware to subscribe to the namespaced form.
  Stops every task/comment/companies/notification handler from waking
  up on every mutation across the system.

Fix #3 β€” Remove JSON.parse(JSON.stringify) from socket hot paths
  Drop five deep clones across taskSocket / commentSocket /
  companiesSocket that existed only to coerce ObjectId to string. Field
  access works directly on the doc; template literals already call
  toString().

Fix #4 β€” Auto-cleanup socketRef.rooms on disconnect
  Listen to Socket.io's native `disconnect` event to purge stale room
  entries. The existing `disconnectNameSpace` flow only fires when the
  client explicitly emits it β€” browser close / network drop / mobile
  background kill previously left dead entries piling up forever.

Fix #6 β€” MongoDB pool sizing
  Bump maxPoolSize 3 -> 10 (env: MONGO_POOL_SIZE), add minPoolSize 2
  (env: MONGO_MIN_POOL_SIZE). The 3-connection cap queued any 4th
  concurrent query per tenant β€” task-heavy flows easily saturated it.

Fix #13 β€” Fail fast on queue saturation
  Reduce waitQueueTimeoutMS 30000 -> 5000 (env: MONGO_WAIT_QUEUE_TIMEOUT_MS).
  A queued query that can't get a socket inside 5s is almost always
  doomed; failing fast surfaces the real problem instead of pinning a
  request worker for 30s.

Verification
  - npm test: 20/20 pass
  - node --check clean on all 9 touched files
  - Namespaced emitter routing smoke-tested
  - Confirmed no `socketEmitter.on('update'|'insert'|...)` legacy
    subscriptions remain anywhere in the codebase
  - Frontend socket inventory audited β€” every emit/on event name is
    preserved on the wire; no client changes required

See .claude/SOCKET-PERFORMANCE-PLAN.md for the full multi-phase plan
and the remaining fixes scheduled for phases 2-4.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* perf(socket): phase 2 core refactor β€” Map-based room index

Phase 2 of SOCKET-PERFORMANCE-PLAN. Backend-only; wire protocol unchanged.

Fix #1 β€” Map-based room index (socket/helper.js)
  Replace the linear `exports.rooms = []` array (scanned by .filter() on
  every event) with two Maps:
    byPrefix : prefix -> Map<roomName, entry>   (O(1) handler lookup)
    bySocket : socket -> Set<roomName>          (O(rooms/socket) cleanup)
  Room names already follow `<prefix>**<socketId>`; we index by exactly
  that prefix. New API: upsertRoom / removeRoom / removeBySocket /
  findRoomsByPrefix / findRoomsByPrefixes.

Fix #5 β€” data.socket.rooms.has(data.roomName) liveness guard
  Replaces `Array.from(data.namespace.adapter.rooms.keys()).filter(...)`
  inside every handler. socket.rooms is a small Set (3-5 entries per
  socket); .has() is O(1) vs the previous full-namespace scan.

Fix #7 β€” Shared upsertRoom helper
  Idempotent on roomName via Map.set semantics β€” tab refresh / reconnect
  no longer creates duplicate index entries that fired the same event to
  the same room multiple times. Replaces hand-rolled findIndex /
  push-or-replace dedup that was inlined inconsistently across the 5
  controllers.

Fix #9 β€” Cache getTotalSprintCount (modules/Tasks/helpers/mongo_helper.js)
  Wrap the aggregate-pipeline + plan-feature check in node-cache with a
  30s TTL keyed by `sprintPlanCheck:<companyId>:<sprintId>`. Trade-off
  (documented inline): up to 30s of over-allocation after limit hit, or
  30s of denial after a plan upgrade. Acceptable for soft plan caps.

socket/socketinit.js
  Drop `exports.rooms = []`. Disconnect handler now calls
  helper.removeBySocket(socket). disconnectNameSpace simplified from the
  recursive countFunction to a plain forEach over adapter.rooms.

Verification
  - npm test: 28/28 pass (20 existing + 8 new tests/socket-room-index.test.js)
  - .claude/tests/smoke-phase2.js: emitter -> namespaced handler -> Map
    index -> namespace.to().emit() flow verified end-to-end
  - All touched files `node --check` clean
  - Frontend audit (frontend/src/**): all wire events (joinX/leaveX, taskX,
    chatTaskX, commentX, companiesX, userIdNoticationUpdate) and room-name
    `<prefix>**<socketId>` convention unchanged

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(tasks): resolve list-view assignee TypeError after Task.vue refactor

The Task.vue mega-component split (#167) extracted changeAssignee into the
useTaskMutations composable and passed assigneeInProgress through the template
as a third argument. Vue 3 auto-unwraps top-level refs in templates, so the
composable received the inner {} object instead of the ref β€” making
assigneeInProgress.value undefined and throwing
"Cannot read properties of undefined (reading '<userId>')" on every assignee
click in list view.

Move the ref into the composable (matching useProjectAssignee.js) so closure
ownership replaces the broken template hand-off.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* In progress changes commit

* fix(ai-project-generator): unblock execute hang, fix list rendering, tighten prompt splitting

Bug fixes:
- orchestrator: wrap critical steps (checkProjectPlan, loadCompanyContext,
  generateUniqueProjectCode, saveProject) with 45s timeouts so a stuck
  MongoDB call surfaces a clear error instead of freezing the UI forever
- orchestrator: roll back the project count whenever checkProjectPlan
  incremented it, not only when a project doc was saved. The old logic
  leaked +1 per failed attempt and eventually tripped the plan limit
- orchestrator: normalize list items to @editorjs/nested-list's
  { content, items: [] } shape β€” the LLM emits plain strings, which the
  editor renders as literal "undefined"
- orchestrator: drop bogus .catch() on removeProjectCount() (it returns
  undefined, not a Promise β€” was throwing TypeError on the error path)
- orchestrator: per-step [AIPG][jobId] logging to pinpoint future hangs
- openai provider: bump axios timeout to 10 min for reasoning/gpt-5
  models (was 4 min, which the enriched prompt + 40+ task generation
  routinely exceeded); env override via OPENAI_TIMEOUT_MS

Prompt improvements (task-guidance.md, examples.md, sprint-guidance.md):
- one task per screen for every team, with explicit design→build symmetry
- one task per HTTP method (no PUT/DELETE skips)
- always include a project scaffolding task for new codebases
- anti-patterns section calling out the bundling shapes seen in past
  outputs ("X and Y", "X with Y", redundant planning docs, QA bundling)
- task count floor by project type, with pre-emit checklist
- drop "Required skills" from descriptions β€” team assignment alone
  communicates the skill set
- bumped recommended OpenAI model in .env.example to gpt-4o / gpt-4.1
  for stronger instruction following

UI:
- show the original brief filename instead of "Brief loaded Β· N tokens"
- sprint-name input takes the full row width in the plan preview

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* revert(staging): temporarily disable hardening middleware and tenant pool cap

- index.js: comment out trust-proxy, helmet, and global rate-limit middleware
- middlewares/mongoConnector: revert issue #162 LRU-bounded tenant connection pool back to the previous unbounded behaviour with the fixed idle/sweep timers
- .env.example: drop the MAX_TENANT_CONNECTIONS / TENANT_CONNECTION_IDLE_MS / TENANT_CONNECTION_SWEEP_MS doc block that the pool cap referenced

This is a deliberate rollback on the staging branch to unblock staging-only behaviour; the hardening should be reinstated once the regression is diagnosed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Add space

* fix(promptBuilder): correct comment formatting for consistency

* fix: consolidate lowercase modules/ into Modules/ to resolve case-folding split

On Windows (case-insensitive filesystem) commits accidentally tracked 12
AIProjectGenerator files under modules/ (lowercase) while the rest of the
codebase lives under Modules/ (uppercase). The local tree merged both, hiding
the issue β€” but on Linux (case-sensitive) GitHub and production saw two
separate folders, breaking ./promptBuilder and path.join(__dirname, 'prompts')
require resolutions at runtime.

Move all 12 files into Modules/AIProjectGenerator/ so the folder is unified
across all platforms.

* feat(tasks): AI-estimate completion time on task create

Estimate task completion time with an LLM after every task is created
and persist the result (in minutes) to `tasks.totalEstimatedTime`. The
estimate represents the wall-clock time an AI coding agent (Claude Code)
would need to do the task end-to-end, with no manual implementation.

Why
- Gives newly-created tasks an immediate, description-driven time
  estimate so planning and remaining-hours math have a value to work
  with from the start.
- Reuses the existing AIProjectGenerator llmProvider factory
  (Anthropic-preferred, OpenAI fallback), so installs that already
  have AI configured pick this up with no new env vars.

How
- New helper `Modules/EstimatedTime/aiTaskEstimator.js`. Reads the
  system prompt once at module load from a Markdown partial,
  builds a structured user message from the task (title, type,
  priority, parent/subtask flag, description), parses JSON,
  clamps to [5 min, 7 days], $sets totalEstimatedTime, and emits
  a `task` socket update. Never throws; silently no-ops when no
  LLM provider is configured.
- New prompt partial `Modules/AIProjectGenerator/prompts/project-plan/
  task-time-estimate.md` walks the model through deliverable shape,
  surface area, ambiguity, verification, and iteration overhead β€”
  explicitly discouraging lazy round-number defaults so the estimate
  reflects the actual description.
- Hook in `Modules/Tasks/helpers/taskMongo/create.js` after the
  central task `create()` succeeds (skips mainChat). Fire-and-forget,
  so API responses are not delayed.
- Hook in `Modules/AIProjectGenerator/orchestrator.js` after the
  bulk `insertMany` so AI-orchestrated project tasks also get
  estimates. Background runner with a concurrency cap of 3 to stay
  under provider rate limits when a single plan generates many tasks.

Docs
- `.claude/AI-TIME-ESTIMATION.md` explains the model the prompt uses
  and how estimates flow through the app in plain language.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(estimated-time): manual AI trigger + accuracy rewrite

Three related improvements to the task time estimation feature built
in #188:

1. Manual AI trigger in the task detail sidebar
   - New icon-only button next to the "Estimated" field. Tooltip
     "Generate estimate using AI" via native title attribute (matches
     existing project tooltip convention).
   - Inline spinner + disabled state while the request is in flight;
     debounce-safe (`isAiEstimateLoading` guards double-clicks).
   - Backed by a new endpoint `POST /api/v1/estimatedTime/ai/:tid`
     in `Modules/EstimatedTime/controller.js` that fetches the
     canonical task doc server-side (so the LLM payload comes from
     the DB, not stale client data), runs the estimator with the new
     `force: true` flag so it overwrites any existing value, persists
     `totalEstimatedTime`, and emits a Socket.io `task` update.
   - Permission gate: button is inner-gated by
     `checkPermission('task.task_estimated_hours', ...) === true`,
     mirroring the read-only / read-write pattern Priority / Start
     Date / Due Date use in the same component. Read-only users still
     see the value, just not the AI trigger.

2. Accuracy rewrite of the estimator prompt
   - The system prompt now walks the model through a four-phase
     pipeline before producing a number:
       Phase 1 β€” Normalize: remove redundancy, merge overlapping
                 requirements, drop filler, keep implicit work.
       Phase 2 β€” Extract:   enumerate the unique work items.
       Phase 3 β€” Estimate:  apply deliverable shape, surface area,
                 clarity, verification, and iteration overhead
                 to EACH item, not to the whole description.
       Phase 4 β€” Sum:       add up the per-item estimates without
                 double-counting shared setup.
   - Output JSON now includes a `work_items[]` array β€” forcing
     chain-of-thought enumeration before the number, which is what
     structurally prevents both inflation (no item -> no minutes) and
     underestimation (each implicit item must be named).
   - Four worked examples baked into the prompt cover redundancy,
     overlapping requirements, filler stripping, and implicit work.
   - Parser is backward compatible: still consumes only `minutes`,
     so tasks estimated by the old prompt shape keep working.

3. Estimator tuning
   - `maxTokens: 256 -> 1024` so the work_items array doesn't get
     truncated mid-JSON (truncated response = parser returns null =
     estimate skipped, so this directly affects success rate).
   - `temperature: 0.2 -> 0.15` pulls the model toward deterministic,
     calibrated outputs β€” estimation should not be creative.
   - New `normalizeDescriptionForPrompt` helper strips trailing
     whitespace and collapses 3+ blank or 3+ accidental-duplicate
     lines so the model spends its reasoning budg…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant